agentxchain 2.146.0 → 2.148.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/package.json +1 -1
- package/scripts/publish-npm.sh +16 -0
- package/scripts/sync-homebrew.sh +14 -1
- package/scripts/verify-post-publish.sh +55 -4
- package/src/commands/reissue-turn.js +16 -0
- package/src/commands/reject-turn.js +14 -1
- package/src/commands/restart.js +15 -0
- package/src/commands/resume.js +61 -66
- package/src/commands/run.js +67 -10
- package/src/commands/schedule.js +34 -7
- package/src/commands/status.js +20 -0
- package/src/commands/step.js +100 -34
- package/src/lib/adapters/api-proxy-adapter.js +8 -0
- package/src/lib/adapters/local-cli-adapter.js +271 -16
- package/src/lib/adapters/manual-adapter.js +9 -10
- package/src/lib/adapters/mcp-adapter.js +3 -5
- package/src/lib/adapters/remote-agent-adapter.js +3 -5
- package/src/lib/continuous-run.js +71 -6
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/dispatch-progress.js +5 -3
- package/src/lib/governed-state.js +258 -17
- package/src/lib/intake.js +10 -1
- package/src/lib/normalized-config.js +51 -1
- package/src/lib/recent-event-summary.js +11 -0
- package/src/lib/run-events.js +4 -0
- package/src/lib/run-loop.js +67 -2
- package/src/lib/runner-interface.js +1 -0
- package/src/lib/schema.js +7 -0
- package/src/lib/schemas/agentxchain-config.schema.json +15 -1
- package/src/lib/schemas/turn-result.schema.json +8 -2
- package/src/lib/staged-result-proof.js +43 -0
- package/src/lib/stale-turn-watchdog.js +218 -90
- package/src/lib/turn-checkpoint.js +65 -1
- package/src/lib/turn-result-shape.js +38 -0
- package/src/lib/turn-result-validator.js +15 -3
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
* auto-route, and does not pretend to be an orchestrator.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import {
|
|
16
16
|
getDispatchPromptPath,
|
|
17
17
|
getTurnStagingResultPath,
|
|
18
18
|
} from '../turn-paths.js';
|
|
19
|
+
import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Print operator instructions for a manual turn.
|
|
22
23
|
*
|
|
23
|
-
* @param {object} state - current governed state (must
|
|
24
|
+
* @param {object} state - current governed state (must expose an active turn via active_turns; current_turn is a non-enumerable compatibility alias re-attached on load, not a persisted schema field)
|
|
24
25
|
* @param {object} config - normalized config
|
|
25
26
|
* @param {object} [options]
|
|
26
27
|
* @param {string} [options.turnId]
|
|
@@ -282,16 +283,14 @@ export async function waitForStagedResult(root, options = {}) {
|
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
/**
|
|
285
|
-
* Check if the staged result file exists and
|
|
286
|
+
* Check if the staged result file exists and has meaningful content.
|
|
287
|
+
* Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
|
|
288
|
+
* manual adapter, and local-cli adapter all agree on what counts as proof.
|
|
289
|
+
* Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001, placeholders (`{}`, blank,
|
|
290
|
+
* whitespace-only, or `{}\n`) are cleanup artifacts, not evidence.
|
|
286
291
|
*/
|
|
287
292
|
function isStagedResultReady(filePath) {
|
|
288
|
-
|
|
289
|
-
if (!existsSync(filePath)) return false;
|
|
290
|
-
const stat = statSync(filePath);
|
|
291
|
-
return stat.size > 2; // Must be more than just "{}" or empty
|
|
292
|
-
} catch {
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
293
|
+
return hasMeaningfulStagedResult(filePath);
|
|
295
294
|
}
|
|
296
295
|
|
|
297
296
|
/**
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getTurnStagingResultPath,
|
|
13
13
|
} from '../turn-paths.js';
|
|
14
14
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
15
|
+
import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_MCP_TOOL_NAME = 'agentxchain_turn';
|
|
17
18
|
export const DEFAULT_MCP_TRANSPORT = 'stdio';
|
|
@@ -237,7 +238,7 @@ export function extractTurnResultFromMcpToolResult(toolResult) {
|
|
|
237
238
|
|
|
238
239
|
for (const block of textBlocks) {
|
|
239
240
|
const parsed = tryParseJson(block.text);
|
|
240
|
-
if (looksLikeTurnResult(parsed)
|
|
241
|
+
if (looksLikeTurnResult(parsed)) {
|
|
241
242
|
return { ok: true, result: parsed };
|
|
242
243
|
}
|
|
243
244
|
}
|
|
@@ -336,10 +337,7 @@ function isPlainObject(value) {
|
|
|
336
337
|
}
|
|
337
338
|
|
|
338
339
|
function looksLikeTurnResult(value) {
|
|
339
|
-
|
|
340
|
-
const hasIdentity = 'run_id' in value || 'turn_id' in value;
|
|
341
|
-
const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
|
|
342
|
-
return hasIdentity && hasLifecycle;
|
|
340
|
+
return hasMinimumTurnResultShape(value);
|
|
343
341
|
}
|
|
344
342
|
|
|
345
343
|
function tryParseJson(value) {
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
getTurnStagingResultPath,
|
|
25
25
|
} from '../turn-paths.js';
|
|
26
26
|
import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
|
|
27
|
+
import { hasMinimumTurnResultShape } from '../turn-result-shape.js';
|
|
27
28
|
|
|
28
29
|
/** Default timeout for remote agent requests (ms). */
|
|
29
30
|
export const DEFAULT_REMOTE_AGENT_TIMEOUT_MS = 120_000;
|
|
@@ -195,7 +196,7 @@ export async function dispatchRemoteAgent(root, state, config, options = {}) {
|
|
|
195
196
|
|
|
196
197
|
// Validate turn result structure (lightweight — full validation happens in the acceptance pipeline)
|
|
197
198
|
if (!looksLikeTurnResult(responseData)) {
|
|
198
|
-
logs.push('[remote] Response missing
|
|
199
|
+
logs.push('[remote] Response missing minimum governed turn-result fields (need schema_version + identity + lifecycle fields)');
|
|
199
200
|
return {
|
|
200
201
|
ok: false,
|
|
201
202
|
error: 'Remote agent response does not contain a valid turn result',
|
|
@@ -230,10 +231,7 @@ export async function dispatchRemoteAgent(root, state, config, options = {}) {
|
|
|
230
231
|
* Full validation happens later via validateStagedTurnResult.
|
|
231
232
|
*/
|
|
232
233
|
function looksLikeTurnResult(value) {
|
|
233
|
-
|
|
234
|
-
const hasIdentity = 'run_id' in value || 'turn_id' in value;
|
|
235
|
-
const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
|
|
236
|
-
return hasIdentity && hasLifecycle;
|
|
234
|
+
return hasMinimumTurnResultShape(value);
|
|
237
235
|
}
|
|
238
236
|
|
|
239
237
|
function resolveTargetTurn(state, turnId) {
|
|
@@ -99,6 +99,9 @@ function describeContinuousTerminalStep(step, contOpts) {
|
|
|
99
99
|
return `Continuous loop failed: ${reason}. Check "agentxchain status" for details.`;
|
|
100
100
|
}
|
|
101
101
|
if (step.status === 'blocked') {
|
|
102
|
+
if (step.recovery_action) {
|
|
103
|
+
return `Continuous loop paused on blocker. Recovery: ${step.recovery_action}`;
|
|
104
|
+
}
|
|
102
105
|
return 'Continuous loop paused on blocker. Use "agentxchain unblock <id>" to resume.';
|
|
103
106
|
}
|
|
104
107
|
return null;
|
|
@@ -116,6 +119,14 @@ function isBlockedContinuousExecution(execution) {
|
|
|
116
119
|
|| stopReason === 'reject_exhausted';
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
function getBlockedRecoveryAction(state) {
|
|
123
|
+
return state?.blocked_reason?.recovery?.recovery_action || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getBlockedCategory(state) {
|
|
127
|
+
return state?.blocked_reason?.category || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
// ---------------------------------------------------------------------------
|
|
120
131
|
// Intake queue check
|
|
121
132
|
// ---------------------------------------------------------------------------
|
|
@@ -361,7 +372,14 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
361
372
|
if (governedState?.status === 'blocked') {
|
|
362
373
|
// Still blocked — stay paused, do not attempt new work
|
|
363
374
|
writeContinuousSession(root, session);
|
|
364
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
ok: true,
|
|
377
|
+
status: 'blocked',
|
|
378
|
+
action: 'still_blocked',
|
|
379
|
+
run_id: session.current_run_id,
|
|
380
|
+
recovery_action: getBlockedRecoveryAction(governedState),
|
|
381
|
+
blocked_category: getBlockedCategory(governedState),
|
|
382
|
+
};
|
|
365
383
|
}
|
|
366
384
|
// Unblocked — resume by continuing the existing governed run directly.
|
|
367
385
|
// Skip the intake pipeline: the run is already in progress, and startIntent
|
|
@@ -388,10 +406,20 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
388
406
|
const resumeStopReason = execution.result?.stop_reason;
|
|
389
407
|
|
|
390
408
|
if (isBlockedContinuousExecution(execution)) {
|
|
409
|
+
const blockedRecoveryAction = getBlockedRecoveryAction(execution?.result?.state || loadProjectState(root, context.config));
|
|
391
410
|
session.status = 'paused';
|
|
392
|
-
log(
|
|
411
|
+
log(blockedRecoveryAction
|
|
412
|
+
? `Resumed run blocked again — continuous loop re-paused. Recovery: ${blockedRecoveryAction}`
|
|
413
|
+
: 'Resumed run blocked again — continuous loop re-paused.');
|
|
393
414
|
writeContinuousSession(root, session);
|
|
394
|
-
return {
|
|
415
|
+
return {
|
|
416
|
+
ok: true,
|
|
417
|
+
status: 'blocked',
|
|
418
|
+
action: 'run_blocked',
|
|
419
|
+
run_id: session.current_run_id,
|
|
420
|
+
recovery_action: blockedRecoveryAction,
|
|
421
|
+
blocked_category: getBlockedCategory(execution?.result?.state || loadProjectState(root, context.config)),
|
|
422
|
+
};
|
|
395
423
|
}
|
|
396
424
|
|
|
397
425
|
if (execution.exitCode !== 0 || !execution.result) {
|
|
@@ -473,9 +501,35 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
473
501
|
return { ok: false, status: 'failed', action: 'prepare_failed', stop_reason: preparedIntent.error, intent_id: targetIntentId };
|
|
474
502
|
}
|
|
475
503
|
|
|
504
|
+
// BUG-53: Auto-chain audit trail. When this advance step seeds a NEXT run
|
|
505
|
+
// (i.e., at least one prior run already completed in this session), emit a
|
|
506
|
+
// `session_continuation` event so operators have a visible record that the
|
|
507
|
+
// loop auto-derived the next vision objective without intervention. Event
|
|
508
|
+
// is emitted BEFORE we overwrite session.current_run_id so previous_run_id
|
|
509
|
+
// reflects the just-completed run and next_run_id reflects the newly
|
|
510
|
+
// prepared one. See HUMAN-ROADMAP BUG-53 fix #4.
|
|
511
|
+
const previousRunId = session.current_run_id;
|
|
512
|
+
const nextObjective = visionObjective || preparedIntent.intent?.charter || null;
|
|
513
|
+
if ((session.runs_completed || 0) >= 1 && previousRunId && previousRunId !== preparedIntent.run_id) {
|
|
514
|
+
emitRunEvent(root, 'session_continuation', {
|
|
515
|
+
run_id: preparedIntent.run_id,
|
|
516
|
+
phase: null,
|
|
517
|
+
status: 'active',
|
|
518
|
+
payload: {
|
|
519
|
+
session_id: session.session_id,
|
|
520
|
+
previous_run_id: previousRunId,
|
|
521
|
+
next_run_id: preparedIntent.run_id,
|
|
522
|
+
next_objective: nextObjective,
|
|
523
|
+
next_intent_id: targetIntentId,
|
|
524
|
+
runs_completed: session.runs_completed || 0,
|
|
525
|
+
trigger: visionObjective ? 'vision_scan' : 'intake',
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
476
530
|
// Execute the governed run
|
|
477
531
|
session.current_run_id = preparedIntent.run_id;
|
|
478
|
-
session.current_vision_objective =
|
|
532
|
+
session.current_vision_objective = nextObjective;
|
|
479
533
|
session.status = 'running';
|
|
480
534
|
writeContinuousSession(root, session);
|
|
481
535
|
|
|
@@ -519,6 +573,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
519
573
|
}
|
|
520
574
|
|
|
521
575
|
if (isBlockedContinuousExecution(execution)) {
|
|
576
|
+
const blockedRecoveryAction = getBlockedRecoveryAction(execution?.result?.state || loadProjectState(root, context.config));
|
|
522
577
|
const resolved = resolveIntent(root, targetIntentId);
|
|
523
578
|
if (!resolved.ok) {
|
|
524
579
|
log(`Continuous resolve error: ${resolved.error}`);
|
|
@@ -527,9 +582,19 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
527
582
|
return { ok: false, status: 'failed', action: 'resolve_failed', stop_reason: resolved.error, intent_id: targetIntentId };
|
|
528
583
|
}
|
|
529
584
|
session.status = 'paused';
|
|
530
|
-
log(
|
|
585
|
+
log(blockedRecoveryAction
|
|
586
|
+
? `Run blocked — continuous loop paused. Recovery: ${blockedRecoveryAction}`
|
|
587
|
+
: 'Run blocked — continuous loop paused. Use `agentxchain unblock <id>` to resume.');
|
|
531
588
|
writeContinuousSession(root, session);
|
|
532
|
-
return {
|
|
589
|
+
return {
|
|
590
|
+
ok: true,
|
|
591
|
+
status: 'blocked',
|
|
592
|
+
action: 'run_blocked',
|
|
593
|
+
run_id: session.current_run_id,
|
|
594
|
+
intent_id: targetIntentId,
|
|
595
|
+
recovery_action: blockedRecoveryAction,
|
|
596
|
+
blocked_category: getBlockedCategory(execution?.result?.state || loadProjectState(root, context.config)),
|
|
597
|
+
};
|
|
533
598
|
}
|
|
534
599
|
|
|
535
600
|
if (stopReason === 'caller_stopped') {
|
|
@@ -55,7 +55,7 @@ const RESERVED_PATHS = [
|
|
|
55
55
|
* Write a dispatch bundle for the currently assigned turn.
|
|
56
56
|
*
|
|
57
57
|
* @param {string} root - project root directory
|
|
58
|
-
* @param {object} state - current governed state (must
|
|
58
|
+
* @param {object} state - current governed state (must expose an active turn via active_turns; current_turn is a non-enumerable compatibility alias re-attached on load, not a persisted schema field)
|
|
59
59
|
* @param {object} config - normalized config
|
|
60
60
|
* @param {object} [opts]
|
|
61
61
|
* @param {string} [opts.turnId]
|
|
@@ -71,9 +71,10 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
|
|
|
71
71
|
runtime_id: turn.runtime_id || null,
|
|
72
72
|
adapter_type,
|
|
73
73
|
started_at: null,
|
|
74
|
+
first_output_at: null,
|
|
74
75
|
last_activity_at: null,
|
|
75
|
-
activity_type: '
|
|
76
|
-
activity_summary: '
|
|
76
|
+
activity_type: 'starting',
|
|
77
|
+
activity_summary: 'Waiting for first output',
|
|
77
78
|
output_lines: 0,
|
|
78
79
|
stderr_lines: 0,
|
|
79
80
|
silent_since: null,
|
|
@@ -123,7 +124,7 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
|
|
|
123
124
|
const now = new Date().toISOString();
|
|
124
125
|
state.started_at = now;
|
|
125
126
|
state.last_activity_at = now;
|
|
126
|
-
state.activity_type = '
|
|
127
|
+
state.activity_type = 'starting';
|
|
127
128
|
state.activity_summary = 'Subprocess started';
|
|
128
129
|
dirty = true;
|
|
129
130
|
writeProgress();
|
|
@@ -137,6 +138,7 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
|
|
|
137
138
|
const now = new Date().toISOString();
|
|
138
139
|
const wasSilent = state.activity_type === 'silent';
|
|
139
140
|
state.last_activity_at = now;
|
|
141
|
+
state.first_output_at = state.first_output_at || now;
|
|
140
142
|
state.activity_type = 'output';
|
|
141
143
|
state.silent_since = null;
|
|
142
144
|
if (stream === 'stderr') {
|
|
@@ -955,6 +955,67 @@ function writeState(root, state) {
|
|
|
955
955
|
safeWriteJson(join(root, STATE_PATH), stripLegacyCurrentTurn(state));
|
|
956
956
|
}
|
|
957
957
|
|
|
958
|
+
export function transitionActiveTurnLifecycle(root, turnId, nextStatus, options = {}) {
|
|
959
|
+
const state = readState(root);
|
|
960
|
+
if (!state) {
|
|
961
|
+
return { ok: false, error: 'No governed state found' };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const activeTurns = { ...(state.active_turns || {}) };
|
|
965
|
+
const turn = activeTurns[turnId];
|
|
966
|
+
if (!turn) {
|
|
967
|
+
return { ok: false, error: `Turn ${turnId} not found in active turns` };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const nowIso = options.at || new Date().toISOString();
|
|
971
|
+
const nextTurn = { ...turn };
|
|
972
|
+
|
|
973
|
+
if (nextStatus === 'dispatched') {
|
|
974
|
+
nextTurn.status = 'dispatched';
|
|
975
|
+
nextTurn.dispatched_at = nowIso;
|
|
976
|
+
delete nextTurn.started_at;
|
|
977
|
+
delete nextTurn.worker_attached_at;
|
|
978
|
+
delete nextTurn.worker_pid;
|
|
979
|
+
delete nextTurn.first_output_at;
|
|
980
|
+
delete nextTurn.first_output_stream;
|
|
981
|
+
emitRunEvent(root, 'turn_dispatched', {
|
|
982
|
+
run_id: state.run_id,
|
|
983
|
+
phase: state.phase,
|
|
984
|
+
status: state.status,
|
|
985
|
+
turn: { turn_id: turnId, role_id: turn.assigned_role },
|
|
986
|
+
intent_id: turn.intake_context?.intent_id || null,
|
|
987
|
+
});
|
|
988
|
+
} else if (nextStatus === 'starting') {
|
|
989
|
+
nextTurn.status = 'starting';
|
|
990
|
+
nextTurn.started_at = nowIso;
|
|
991
|
+
nextTurn.worker_attached_at = nowIso;
|
|
992
|
+
if (options.pid != null) {
|
|
993
|
+
nextTurn.worker_pid = options.pid;
|
|
994
|
+
}
|
|
995
|
+
} else if (nextStatus === 'running') {
|
|
996
|
+
nextTurn.status = 'running';
|
|
997
|
+
nextTurn.started_at = nextTurn.started_at || nowIso;
|
|
998
|
+
nextTurn.first_output_at = nextTurn.first_output_at || nowIso;
|
|
999
|
+
if (options.stream) {
|
|
1000
|
+
nextTurn.first_output_stream = nextTurn.first_output_stream || options.stream;
|
|
1001
|
+
}
|
|
1002
|
+
} else {
|
|
1003
|
+
return { ok: false, error: `Unsupported turn lifecycle status: ${nextStatus}` };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
activeTurns[turnId] = nextTurn;
|
|
1007
|
+
const nextState = {
|
|
1008
|
+
...state,
|
|
1009
|
+
active_turns: activeTurns,
|
|
1010
|
+
};
|
|
1011
|
+
writeState(root, nextState);
|
|
1012
|
+
return {
|
|
1013
|
+
ok: true,
|
|
1014
|
+
state: attachLegacyCurrentTurnAlias(nextState),
|
|
1015
|
+
turn: attachLegacyCurrentTurnAlias(nextState).active_turns[turnId],
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
958
1019
|
function appendJsonl(root, relPath, entry) {
|
|
959
1020
|
const filePath = join(root, relPath);
|
|
960
1021
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
@@ -2459,6 +2520,144 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2459
2520
|
};
|
|
2460
2521
|
}
|
|
2461
2522
|
|
|
2523
|
+
export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null) {
|
|
2524
|
+
const currentState = state && typeof state === 'object' ? state : readState(root);
|
|
2525
|
+
if (!currentState) {
|
|
2526
|
+
return { ok: false, error: 'No governed state.json found' };
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
if (currentState.status !== 'active' || getActiveTurnCount(currentState) > 0) {
|
|
2530
|
+
return {
|
|
2531
|
+
ok: true,
|
|
2532
|
+
state: attachLegacyCurrentTurnAlias(currentState),
|
|
2533
|
+
advanced: false,
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
const gateFailure = currentState.last_gate_failure;
|
|
2538
|
+
if (gateFailure?.gate_type !== 'phase_transition') {
|
|
2539
|
+
return {
|
|
2540
|
+
ok: true,
|
|
2541
|
+
state: attachLegacyCurrentTurnAlias(currentState),
|
|
2542
|
+
advanced: false,
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
const historyEntries = readJsonlEntries(root, HISTORY_PATH);
|
|
2547
|
+
const phaseSource = findHistoryTurnRequest(
|
|
2548
|
+
historyEntries,
|
|
2549
|
+
gateFailure.requested_by_turn || currentState.last_completed_turn_id || null,
|
|
2550
|
+
'phase_transition',
|
|
2551
|
+
);
|
|
2552
|
+
if (!phaseSource?.phase_transition_request) {
|
|
2553
|
+
return {
|
|
2554
|
+
ok: true,
|
|
2555
|
+
state: attachLegacyCurrentTurnAlias(currentState),
|
|
2556
|
+
advanced: false,
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
const gateResult = evaluatePhaseExit({
|
|
2561
|
+
state: { ...currentState, history: historyEntries },
|
|
2562
|
+
config,
|
|
2563
|
+
acceptedTurn: phaseSource,
|
|
2564
|
+
root,
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
if (gateResult.action === 'awaiting_human_approval') {
|
|
2568
|
+
const pausedState = {
|
|
2569
|
+
...currentState,
|
|
2570
|
+
status: 'paused',
|
|
2571
|
+
blocked_on: `human_approval:${gateResult.gate_id}`,
|
|
2572
|
+
blocked_reason: null,
|
|
2573
|
+
pending_phase_transition: {
|
|
2574
|
+
from: currentState.phase,
|
|
2575
|
+
to: gateResult.next_phase,
|
|
2576
|
+
gate: gateResult.gate_id,
|
|
2577
|
+
requested_by_turn: phaseSource.turn_id,
|
|
2578
|
+
requested_at: new Date().toISOString(),
|
|
2579
|
+
},
|
|
2580
|
+
};
|
|
2581
|
+
writeState(root, pausedState);
|
|
2582
|
+
const approved = approvePhaseTransition(root, config);
|
|
2583
|
+
return {
|
|
2584
|
+
ok: approved.ok,
|
|
2585
|
+
error: approved.error,
|
|
2586
|
+
state: approved.state || attachLegacyCurrentTurnAlias(readState(root)),
|
|
2587
|
+
advanced: approved.ok,
|
|
2588
|
+
from_phase: currentState.phase,
|
|
2589
|
+
to_phase: approved.state?.phase || gateResult.next_phase || null,
|
|
2590
|
+
gate_id: gateResult.gate_id || null,
|
|
2591
|
+
gateResult,
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
if (gateResult.action !== 'advance') {
|
|
2596
|
+
return {
|
|
2597
|
+
ok: true,
|
|
2598
|
+
state: attachLegacyCurrentTurnAlias(currentState),
|
|
2599
|
+
advanced: false,
|
|
2600
|
+
gateResult,
|
|
2601
|
+
};
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
const now = new Date().toISOString();
|
|
2605
|
+
const prevPhase = currentState.phase;
|
|
2606
|
+
const nextState = {
|
|
2607
|
+
...currentState,
|
|
2608
|
+
phase: gateResult.next_phase,
|
|
2609
|
+
phase_entered_at: now,
|
|
2610
|
+
blocked_on: null,
|
|
2611
|
+
blocked_reason: null,
|
|
2612
|
+
last_gate_failure: null,
|
|
2613
|
+
pending_phase_transition: null,
|
|
2614
|
+
queued_phase_transition: null,
|
|
2615
|
+
phase_gate_status: {
|
|
2616
|
+
...(currentState.phase_gate_status || {}),
|
|
2617
|
+
[gateResult.gate_id || 'no_gate']: 'passed',
|
|
2618
|
+
},
|
|
2619
|
+
};
|
|
2620
|
+
|
|
2621
|
+
writeState(root, nextState);
|
|
2622
|
+
const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
|
|
2623
|
+
if (retiredIntentIds.length > 0) {
|
|
2624
|
+
emitRunEvent(root, 'intent_retired_by_phase_advance', {
|
|
2625
|
+
run_id: nextState.run_id,
|
|
2626
|
+
phase: nextState.phase,
|
|
2627
|
+
status: nextState.status,
|
|
2628
|
+
turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
|
|
2629
|
+
payload: {
|
|
2630
|
+
exited_phase: prevPhase,
|
|
2631
|
+
entered_phase: gateResult.next_phase,
|
|
2632
|
+
retired_count: retiredIntentIds.length,
|
|
2633
|
+
retired_intent_ids: retiredIntentIds,
|
|
2634
|
+
},
|
|
2635
|
+
});
|
|
2636
|
+
}
|
|
2637
|
+
emitRunEvent(root, 'phase_entered', {
|
|
2638
|
+
run_id: nextState.run_id,
|
|
2639
|
+
phase: nextState.phase,
|
|
2640
|
+
status: nextState.status,
|
|
2641
|
+
turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
|
|
2642
|
+
payload: {
|
|
2643
|
+
from: prevPhase,
|
|
2644
|
+
to: gateResult.next_phase,
|
|
2645
|
+
gate_id: gateResult.gate_id || 'no_gate',
|
|
2646
|
+
trigger: 'reconciled_before_dispatch',
|
|
2647
|
+
},
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
return {
|
|
2651
|
+
ok: true,
|
|
2652
|
+
state: attachLegacyCurrentTurnAlias(nextState),
|
|
2653
|
+
advanced: true,
|
|
2654
|
+
from_phase: prevPhase,
|
|
2655
|
+
to_phase: gateResult.next_phase,
|
|
2656
|
+
gate_id: gateResult.gate_id || null,
|
|
2657
|
+
gateResult,
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2462
2661
|
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
2463
2662
|
|
|
2464
2663
|
/**
|
|
@@ -2760,9 +2959,9 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
|
2760
2959
|
const newTurn = {
|
|
2761
2960
|
turn_id: turnId,
|
|
2762
2961
|
assigned_role: roleId,
|
|
2763
|
-
status: '
|
|
2962
|
+
status: 'assigned',
|
|
2764
2963
|
attempt: 1,
|
|
2765
|
-
|
|
2964
|
+
assigned_at: now,
|
|
2766
2965
|
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
2767
2966
|
runtime_id: runtimeId,
|
|
2768
2967
|
baseline,
|
|
@@ -2812,14 +3011,6 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
|
2812
3011
|
|
|
2813
3012
|
writeState(root, updatedState);
|
|
2814
3013
|
|
|
2815
|
-
emitRunEvent(root, 'turn_dispatched', {
|
|
2816
|
-
run_id: updatedState.run_id,
|
|
2817
|
-
phase: updatedState.phase,
|
|
2818
|
-
status: updatedState.status,
|
|
2819
|
-
turn: { turn_id: turnId, role_id: roleId },
|
|
2820
|
-
intent_id: options.intakeContext?.intent_id || null,
|
|
2821
|
-
});
|
|
2822
|
-
|
|
2823
3014
|
// Session checkpoint — non-fatal, written after every successful turn assignment.
|
|
2824
3015
|
// Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
|
|
2825
3016
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
@@ -3032,9 +3223,9 @@ export function reissueTurn(root, config, opts = {}) {
|
|
|
3032
3223
|
const newTurn = {
|
|
3033
3224
|
turn_id: newTurnId,
|
|
3034
3225
|
assigned_role: roleId,
|
|
3035
|
-
status: '
|
|
3226
|
+
status: 'assigned',
|
|
3036
3227
|
attempt: (oldTurn.attempt || 1) + 1,
|
|
3037
|
-
|
|
3228
|
+
assigned_at: now,
|
|
3038
3229
|
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
3039
3230
|
runtime_id: currentRuntimeId,
|
|
3040
3231
|
baseline: newBaseline,
|
|
@@ -3053,10 +3244,30 @@ export function reissueTurn(root, config, opts = {}) {
|
|
|
3053
3244
|
delete newActiveTurns[turnId];
|
|
3054
3245
|
newActiveTurns[newTurnId] = newTurn;
|
|
3055
3246
|
|
|
3247
|
+
// BUG-51 fix #6 (reissue path): release the old turn's reservation and create
|
|
3248
|
+
// a fresh reservation for the new turn. Without this, an operator who runs
|
|
3249
|
+
// `reissue-turn` before the watchdog fires (e.g., drift recovery, or
|
|
3250
|
+
// operator-initiated reissue) leaves the old turn's reservation lingering in
|
|
3251
|
+
// budget_reservations and the new turn carries no budget tracking at all.
|
|
3252
|
+
// The watchdog paths (reconcileStaleTurns / failTurnStartup) already release
|
|
3253
|
+
// on stalled/failed_start; this closes the same hole on the reissue surface.
|
|
3254
|
+
const newReservations = { ...(state.budget_reservations || {}) };
|
|
3255
|
+
delete newReservations[turnId];
|
|
3256
|
+
const reissueEstimate = estimateTurnBudget(config, roleId);
|
|
3257
|
+
if (reissueEstimate > 0) {
|
|
3258
|
+
newReservations[newTurnId] = {
|
|
3259
|
+
reserved_usd: reissueEstimate,
|
|
3260
|
+
role_id: roleId,
|
|
3261
|
+
created_at: now,
|
|
3262
|
+
reissued_from: oldTurn.turn_id,
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3056
3266
|
const updatedState = {
|
|
3057
3267
|
...state,
|
|
3058
3268
|
turn_sequence: nextSequence,
|
|
3059
3269
|
active_turns: newActiveTurns,
|
|
3270
|
+
budget_reservations: newReservations,
|
|
3060
3271
|
};
|
|
3061
3272
|
|
|
3062
3273
|
writeState(root, updatedState);
|
|
@@ -3518,8 +3729,37 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3518
3729
|
],
|
|
3519
3730
|
);
|
|
3520
3731
|
if (!dirtyParity.clean) {
|
|
3521
|
-
|
|
3522
|
-
|
|
3732
|
+
// BUG-55 sub-defect B: when the turn declared verification commands or
|
|
3733
|
+
// machine evidence, undeclared dirty files are most likely verification
|
|
3734
|
+
// outputs that need classification under verification.produced_files
|
|
3735
|
+
// (disposition 'ignore' to clean up, or 'artifact' to include in the
|
|
3736
|
+
// checkpoint). Surface a dedicated error class + message so the agent
|
|
3737
|
+
// knows the correct remediation surface, instead of the generic
|
|
3738
|
+
// files_changed-or-produced_files-or-clean advice that the non-
|
|
3739
|
+
// verification path emits.
|
|
3740
|
+
const verification = turnResult.verification && typeof turnResult.verification === 'object'
|
|
3741
|
+
? turnResult.verification
|
|
3742
|
+
: {};
|
|
3743
|
+
const declaredVerificationCommands = Array.isArray(verification.commands)
|
|
3744
|
+
&& verification.commands.some((c) => typeof c === 'string' && c.trim().length > 0);
|
|
3745
|
+
const declaredMachineEvidence = Array.isArray(verification.machine_evidence)
|
|
3746
|
+
&& verification.machine_evidence.some((e) => e && typeof e === 'object' && typeof e.command === 'string' && e.command.trim().length > 0);
|
|
3747
|
+
const verificationWasDeclared = declaredVerificationCommands || declaredMachineEvidence;
|
|
3748
|
+
|
|
3749
|
+
let failureReason = dirtyParity.reason;
|
|
3750
|
+
let failureErrorCode = 'artifact_dirty_tree_mismatch';
|
|
3751
|
+
if (verificationWasDeclared) {
|
|
3752
|
+
failureErrorCode = 'undeclared_verification_outputs';
|
|
3753
|
+
const undeclared = Array.isArray(dirtyParity.unexpected_dirty_files)
|
|
3754
|
+
? dirtyParity.unexpected_dirty_files
|
|
3755
|
+
: [];
|
|
3756
|
+
const listForMessage = undeclared.slice(0, 5).join(', ')
|
|
3757
|
+
+ (undeclared.length > 5 ? '...' : '');
|
|
3758
|
+
failureReason = `Verification was declared (commands or machine_evidence), but these files are dirty and not classified: ${listForMessage}. Classify each under verification.produced_files with disposition "ignore" (the file should be cleaned up after replay) or "artifact" (the file should be checkpointed as part of the turn), OR add it to files_changed if it is a core turn mutation. Acceptance cannot proceed until the declared contract matches the working tree.`;
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
transitionToFailedAcceptance(root, state, currentTurn, failureReason, {
|
|
3762
|
+
error_code: failureErrorCode,
|
|
3523
3763
|
stage: 'artifact_observation',
|
|
3524
3764
|
extra: {
|
|
3525
3765
|
unexpected_dirty_files: dirtyParity.unexpected_dirty_files,
|
|
@@ -3528,13 +3768,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3528
3768
|
});
|
|
3529
3769
|
return {
|
|
3530
3770
|
ok: false,
|
|
3531
|
-
error:
|
|
3771
|
+
error: failureReason,
|
|
3772
|
+
error_code: failureErrorCode,
|
|
3532
3773
|
validation: {
|
|
3533
3774
|
...validation,
|
|
3534
3775
|
ok: false,
|
|
3535
3776
|
stage: 'artifact_observation',
|
|
3536
3777
|
error_class: 'artifact_error',
|
|
3537
|
-
errors: [
|
|
3778
|
+
errors: [failureReason],
|
|
3538
3779
|
warnings: validation.warnings,
|
|
3539
3780
|
},
|
|
3540
3781
|
};
|
|
@@ -5175,7 +5416,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
5175
5416
|
* Reject a governed turn.
|
|
5176
5417
|
*
|
|
5177
5418
|
* 1. Preserve the invalid staged artifact under .agentxchain/dispatch/rejected/
|
|
5178
|
-
* 2. Increment
|
|
5419
|
+
* 2. Increment the active turn's attempt counter or escalate if retries exhausted
|
|
5179
5420
|
* 3. Clear staging file
|
|
5180
5421
|
*
|
|
5181
5422
|
* Does NOT append to history.jsonl or decision-ledger.jsonl.
|
package/src/lib/intake.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
assignGovernedTurn,
|
|
9
9
|
getActiveTurns,
|
|
10
10
|
getActiveTurnCount,
|
|
11
|
+
transitionActiveTurnLifecycle,
|
|
11
12
|
STATE_PATH,
|
|
12
13
|
} from './governed-state.js';
|
|
13
14
|
import { loadProjectContext, loadProjectState } from './config.js';
|
|
@@ -1090,10 +1091,18 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
1090
1091
|
return { ok: false, error: `dispatch bundle failed: ${bundleResult.error}`, exitCode: 1 };
|
|
1091
1092
|
}
|
|
1092
1093
|
|
|
1093
|
-
finalizeDispatchManifest(root, assignedTurn.turn_id, {
|
|
1094
|
+
const manifestResult = finalizeDispatchManifest(root, assignedTurn.turn_id, {
|
|
1094
1095
|
run_id: state.run_id,
|
|
1095
1096
|
role: assignedTurn.assigned_role,
|
|
1096
1097
|
});
|
|
1098
|
+
if (!manifestResult.ok) {
|
|
1099
|
+
return { ok: false, error: `dispatch manifest failed: ${manifestResult.error}`, exitCode: 1 };
|
|
1100
|
+
}
|
|
1101
|
+
const dispatched = transitionActiveTurnLifecycle(root, assignedTurn.turn_id, 'dispatched');
|
|
1102
|
+
if (!dispatched.ok) {
|
|
1103
|
+
return { ok: false, error: `dispatch lifecycle transition failed: ${dispatched.error}`, exitCode: 1 };
|
|
1104
|
+
}
|
|
1105
|
+
state = dispatched.state;
|
|
1097
1106
|
}
|
|
1098
1107
|
|
|
1099
1108
|
// Update intent: planned → executing
|