agentxchain 2.145.0 → 2.147.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/dashboard/app.js +3 -0
- package/dashboard/components/notifications.js +127 -0
- package/dashboard/index.html +1 -0
- package/package.json +1 -1
- package/scripts/publish-npm.sh +16 -0
- package/scripts/release-downstream-truth.sh +16 -8
- package/scripts/sync-homebrew.sh +14 -1
- package/scripts/verify-post-publish.sh +55 -4
- package/src/commands/init.js +66 -31
- package/src/commands/reissue-turn.js +16 -0
- package/src/commands/reject-turn.js +14 -1
- package/src/commands/restart.js +33 -3
- package/src/commands/resume.js +78 -66
- package/src/commands/run.js +67 -10
- package/src/commands/schedule.js +34 -7
- package/src/commands/status.js +38 -5
- package/src/commands/step.js +117 -34
- package/src/lib/adapters/api-proxy-adapter.js +8 -0
- package/src/lib/adapters/local-cli-adapter.js +131 -13
- 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/config.js +4 -1
- package/src/lib/continuous-run.js +71 -6
- package/src/lib/dashboard/actions.js +9 -3
- package/src/lib/dashboard/bridge-server.js +11 -0
- package/src/lib/dashboard/notifications-reader.js +91 -0
- package/src/lib/dashboard/state-reader.js +16 -4
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/dispatch-progress.js +5 -3
- package/src/lib/governed-state.js +355 -13
- package/src/lib/intake.js +10 -1
- package/src/lib/normalized-config.js +51 -1
- package/src/lib/recent-event-summary.js +12 -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/staged-result-proof.js +43 -0
- package/src/lib/stale-turn-watchdog.js +308 -34
- package/src/lib/turn-result-shape.js +38 -0
- package/src/lib/turn-result-validator.js +4 -1
|
@@ -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 });
|
|
@@ -1894,6 +1955,137 @@ export function reconcileRecoveryActionsWithConfig(state, config) {
|
|
|
1894
1955
|
return { state: nextState, changed };
|
|
1895
1956
|
}
|
|
1896
1957
|
|
|
1958
|
+
function inferApprovalPauseFromState(state, config) {
|
|
1959
|
+
if (!state || typeof state !== 'object' || !config) {
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
if (state.pending_run_completion?.gate) {
|
|
1964
|
+
return {
|
|
1965
|
+
gateType: 'run_completion',
|
|
1966
|
+
gateId: state.pending_run_completion.gate,
|
|
1967
|
+
pendingField: 'pending_run_completion',
|
|
1968
|
+
pendingValue: state.pending_run_completion,
|
|
1969
|
+
typedReason: 'pending_run_completion',
|
|
1970
|
+
recoveryAction: 'agentxchain approve-completion',
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (state.pending_phase_transition?.gate) {
|
|
1975
|
+
return {
|
|
1976
|
+
gateType: 'phase_transition',
|
|
1977
|
+
gateId: state.pending_phase_transition.gate,
|
|
1978
|
+
pendingField: 'pending_phase_transition',
|
|
1979
|
+
pendingValue: state.pending_phase_transition,
|
|
1980
|
+
typedReason: 'pending_phase_transition',
|
|
1981
|
+
recoveryAction: 'agentxchain approve-transition',
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Approval waits are post-turn pause states. If a turn is still retained,
|
|
1986
|
+
// recover the turn first instead of synthesizing a gate wait from stale
|
|
1987
|
+
// blocked_on metadata.
|
|
1988
|
+
if (getActiveTurnCount(state) > 0) {
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
if (typeof state.blocked_on !== 'string' || !state.blocked_on.startsWith('human_approval:')) {
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const gateId = state.blocked_on.slice('human_approval:'.length) || null;
|
|
1997
|
+
const currentRouting = config.routing?.[state.phase];
|
|
1998
|
+
if (!gateId || !currentRouting?.exit_gate || currentRouting.exit_gate !== gateId) {
|
|
1999
|
+
return null;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const requestedByTurn = state.blocked_reason?.turn_id ?? state.last_completed_turn_id ?? null;
|
|
2003
|
+
const nextPhase = getNextPhase(state.phase, config.routing || {});
|
|
2004
|
+
|
|
2005
|
+
if (nextPhase) {
|
|
2006
|
+
return {
|
|
2007
|
+
gateType: 'phase_transition',
|
|
2008
|
+
gateId,
|
|
2009
|
+
pendingField: 'pending_phase_transition',
|
|
2010
|
+
pendingValue: {
|
|
2011
|
+
from: state.phase,
|
|
2012
|
+
to: nextPhase,
|
|
2013
|
+
gate: gateId,
|
|
2014
|
+
requested_by_turn: requestedByTurn,
|
|
2015
|
+
},
|
|
2016
|
+
typedReason: 'pending_phase_transition',
|
|
2017
|
+
recoveryAction: 'agentxchain approve-transition',
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
return {
|
|
2022
|
+
gateType: 'run_completion',
|
|
2023
|
+
gateId,
|
|
2024
|
+
pendingField: 'pending_run_completion',
|
|
2025
|
+
pendingValue: {
|
|
2026
|
+
gate: gateId,
|
|
2027
|
+
requested_by_turn: requestedByTurn,
|
|
2028
|
+
},
|
|
2029
|
+
typedReason: 'pending_run_completion',
|
|
2030
|
+
recoveryAction: 'agentxchain approve-completion',
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
export function reconcileApprovalPausesWithConfig(state, config) {
|
|
2035
|
+
if (!state || typeof state !== 'object' || !config) {
|
|
2036
|
+
return { state, changed: false };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
const inferred = inferApprovalPauseFromState(state, config);
|
|
2040
|
+
if (!inferred) {
|
|
2041
|
+
return { state, changed: false };
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
let nextState = state;
|
|
2045
|
+
let changed = false;
|
|
2046
|
+
|
|
2047
|
+
if (!state[inferred.pendingField]) {
|
|
2048
|
+
nextState = {
|
|
2049
|
+
...nextState,
|
|
2050
|
+
[inferred.pendingField]: inferred.pendingValue,
|
|
2051
|
+
};
|
|
2052
|
+
changed = true;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (nextState.status === 'blocked' || nextState.blocked_reason != null) {
|
|
2056
|
+
nextState = {
|
|
2057
|
+
...nextState,
|
|
2058
|
+
status: 'paused',
|
|
2059
|
+
blocked_reason: null,
|
|
2060
|
+
};
|
|
2061
|
+
changed = true;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const recovery = nextState.blocked_reason?.recovery;
|
|
2065
|
+
if (recovery && (
|
|
2066
|
+
recovery.typed_reason !== inferred.typedReason
|
|
2067
|
+
|| recovery.recovery_action !== inferred.recoveryAction
|
|
2068
|
+
|| recovery.detail !== inferred.gateId
|
|
2069
|
+
)) {
|
|
2070
|
+
nextState = {
|
|
2071
|
+
...nextState,
|
|
2072
|
+
blocked_reason: {
|
|
2073
|
+
...nextState.blocked_reason,
|
|
2074
|
+
recovery: {
|
|
2075
|
+
...recovery,
|
|
2076
|
+
typed_reason: inferred.typedReason,
|
|
2077
|
+
recovery_action: inferred.recoveryAction,
|
|
2078
|
+
turn_retained: false,
|
|
2079
|
+
detail: inferred.gateId,
|
|
2080
|
+
},
|
|
2081
|
+
},
|
|
2082
|
+
};
|
|
2083
|
+
changed = true;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
return { state: nextState, changed };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1897
2089
|
function inferBlockedReasonFromState(state) {
|
|
1898
2090
|
if (!state || typeof state !== 'object') {
|
|
1899
2091
|
return null;
|
|
@@ -2328,6 +2520,144 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2328
2520
|
};
|
|
2329
2521
|
}
|
|
2330
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
|
+
|
|
2331
2661
|
// ── Core Operations ──────────────────────────────────────────────────────────
|
|
2332
2662
|
|
|
2333
2663
|
/**
|
|
@@ -2629,9 +2959,9 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
|
2629
2959
|
const newTurn = {
|
|
2630
2960
|
turn_id: turnId,
|
|
2631
2961
|
assigned_role: roleId,
|
|
2632
|
-
status: '
|
|
2962
|
+
status: 'assigned',
|
|
2633
2963
|
attempt: 1,
|
|
2634
|
-
|
|
2964
|
+
assigned_at: now,
|
|
2635
2965
|
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
2636
2966
|
runtime_id: runtimeId,
|
|
2637
2967
|
baseline,
|
|
@@ -2681,14 +3011,6 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
|
2681
3011
|
|
|
2682
3012
|
writeState(root, updatedState);
|
|
2683
3013
|
|
|
2684
|
-
emitRunEvent(root, 'turn_dispatched', {
|
|
2685
|
-
run_id: updatedState.run_id,
|
|
2686
|
-
phase: updatedState.phase,
|
|
2687
|
-
status: updatedState.status,
|
|
2688
|
-
turn: { turn_id: turnId, role_id: roleId },
|
|
2689
|
-
intent_id: options.intakeContext?.intent_id || null,
|
|
2690
|
-
});
|
|
2691
|
-
|
|
2692
3014
|
// Session checkpoint — non-fatal, written after every successful turn assignment.
|
|
2693
3015
|
// Pass the captured baseline so session.json agrees with state.json (BUG-2 fix).
|
|
2694
3016
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
@@ -2901,9 +3223,9 @@ export function reissueTurn(root, config, opts = {}) {
|
|
|
2901
3223
|
const newTurn = {
|
|
2902
3224
|
turn_id: newTurnId,
|
|
2903
3225
|
assigned_role: roleId,
|
|
2904
|
-
status: '
|
|
3226
|
+
status: 'assigned',
|
|
2905
3227
|
attempt: (oldTurn.attempt || 1) + 1,
|
|
2906
|
-
|
|
3228
|
+
assigned_at: now,
|
|
2907
3229
|
deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
|
|
2908
3230
|
runtime_id: currentRuntimeId,
|
|
2909
3231
|
baseline: newBaseline,
|
|
@@ -2922,10 +3244,30 @@ export function reissueTurn(root, config, opts = {}) {
|
|
|
2922
3244
|
delete newActiveTurns[turnId];
|
|
2923
3245
|
newActiveTurns[newTurnId] = newTurn;
|
|
2924
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
|
+
|
|
2925
3266
|
const updatedState = {
|
|
2926
3267
|
...state,
|
|
2927
3268
|
turn_sequence: nextSequence,
|
|
2928
3269
|
active_turns: newActiveTurns,
|
|
3270
|
+
budget_reservations: newReservations,
|
|
2929
3271
|
};
|
|
2930
3272
|
|
|
2931
3273
|
writeState(root, updatedState);
|
|
@@ -5044,7 +5386,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
5044
5386
|
* Reject a governed turn.
|
|
5045
5387
|
*
|
|
5046
5388
|
* 1. Preserve the invalid staged artifact under .agentxchain/dispatch/rejected/
|
|
5047
|
-
* 2. Increment
|
|
5389
|
+
* 2. Increment the active turn's attempt counter or escalate if retries exhausted
|
|
5048
5390
|
* 3. Clear staging file
|
|
5049
5391
|
*
|
|
5050
5392
|
* 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
|
|
@@ -395,6 +395,11 @@ export function validateV4Config(data, projectRoot) {
|
|
|
395
395
|
} else {
|
|
396
396
|
if (typeof data.project.id !== 'string' || !data.project.id.trim()) errors.push('project.id must be a non-empty string');
|
|
397
397
|
if (typeof data.project.name !== 'string' || !data.project.name.trim()) errors.push('project.name must be a non-empty string');
|
|
398
|
+
if ('default_branch' in data.project) {
|
|
399
|
+
if (typeof data.project.default_branch !== 'string' || !data.project.default_branch.trim()) {
|
|
400
|
+
errors.push('project.default_branch must be a non-empty string when provided');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
398
403
|
// Optional project.goal field
|
|
399
404
|
if (data.project.goal !== undefined && data.project.goal !== null) {
|
|
400
405
|
if (typeof data.project.goal !== 'string') {
|
|
@@ -480,6 +485,17 @@ export function validateV4Config(data, projectRoot) {
|
|
|
480
485
|
}
|
|
481
486
|
}
|
|
482
487
|
}
|
|
488
|
+
// Schema publishes max_output_tokens as `integer, minimum: 1`. The
|
|
489
|
+
// api-proxy adapter silently falls back to 4096 on `0` / null /
|
|
490
|
+
// undefined and passes negative/non-integer values straight through
|
|
491
|
+
// to the provider, which is the same silent-fallback defect class
|
|
492
|
+
// the run_loop watchdog knobs had (DEC-SILENT-FALLBACK-DEFECT-CLASS-001).
|
|
493
|
+
// Reject at write time so the operator sees the bad value immediately.
|
|
494
|
+
if ('max_output_tokens' in rt) {
|
|
495
|
+
if (!Number.isInteger(rt.max_output_tokens) || rt.max_output_tokens < 1) {
|
|
496
|
+
errors.push(`Runtime "${id}": max_output_tokens must be a positive integer`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
483
499
|
if ('retry_policy' in rt) {
|
|
484
500
|
validateApiProxyRetryPolicy(id, rt.retry_policy, errors);
|
|
485
501
|
}
|
|
@@ -597,6 +613,14 @@ export function validateV4Config(data, projectRoot) {
|
|
|
597
613
|
errors.push(...timeoutValidation.errors);
|
|
598
614
|
}
|
|
599
615
|
|
|
616
|
+
// Run-loop watchdog knobs (BUG-47 / BUG-51). Schema publishes both as
|
|
617
|
+
// positive integers; runtime silently falls back to defaults on bad input,
|
|
618
|
+
// which misleads operators into thinking their config --set took effect.
|
|
619
|
+
// Reject at config-write / validate time so the operator sees the problem.
|
|
620
|
+
if (data.run_loop !== undefined) {
|
|
621
|
+
errors.push(...validateRunLoopConfig(data.run_loop));
|
|
622
|
+
}
|
|
623
|
+
|
|
600
624
|
// Admission control (ADM-001..004) is handled by the validate, doctor, and
|
|
601
625
|
// run-loop paths which call runAdmissionControl() directly. Config schema
|
|
602
626
|
// validation here should not duplicate that surface.
|
|
@@ -604,6 +628,30 @@ export function validateV4Config(data, projectRoot) {
|
|
|
604
628
|
return { ok: errors.length === 0, errors, warnings };
|
|
605
629
|
}
|
|
606
630
|
|
|
631
|
+
export function validateRunLoopConfig(runLoop) {
|
|
632
|
+
const errors = [];
|
|
633
|
+
if (runLoop === null || typeof runLoop !== 'object' || Array.isArray(runLoop)) {
|
|
634
|
+
errors.push('run_loop must be an object');
|
|
635
|
+
return errors;
|
|
636
|
+
}
|
|
637
|
+
validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
|
|
638
|
+
validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
|
|
639
|
+
return errors;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function validateRunLoopPositiveInteger(path, value, errors) {
|
|
643
|
+
if (value === undefined || value === null) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
647
|
+
errors.push(`${path} must be a positive integer (milliseconds)`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (value < 1) {
|
|
651
|
+
errors.push(`${path} must be a positive integer (milliseconds)`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
607
655
|
export function validateBudgetConfig(budget) {
|
|
608
656
|
const errors = [];
|
|
609
657
|
|
|
@@ -1145,7 +1193,9 @@ export function normalizeV4(raw) {
|
|
|
1145
1193
|
id: raw.project?.id || 'unknown',
|
|
1146
1194
|
name: raw.project?.name || 'Unknown',
|
|
1147
1195
|
...(typeof raw.project?.goal === 'string' && raw.project.goal.trim() ? { goal: raw.project.goal.trim() } : {}),
|
|
1148
|
-
default_branch: raw.project?.default_branch
|
|
1196
|
+
default_branch: typeof raw.project?.default_branch === 'string' && raw.project.default_branch.trim()
|
|
1197
|
+
? raw.project.default_branch.trim()
|
|
1198
|
+
: 'main',
|
|
1149
1199
|
},
|
|
1150
1200
|
roles,
|
|
1151
1201
|
runtimes: raw.runtimes || {},
|
|
@@ -54,6 +54,9 @@ function describeEvent(eventType, entry) {
|
|
|
54
54
|
}
|
|
55
55
|
case 'turn_checkpointed':
|
|
56
56
|
case 'turn_stalled':
|
|
57
|
+
case 'turn_start_failed':
|
|
58
|
+
case 'runtime_spawn_failed':
|
|
59
|
+
case 'stdout_attach_failed':
|
|
57
60
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
58
61
|
case 'dispatch_progress':
|
|
59
62
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
@@ -76,6 +79,15 @@ function describeEvent(eventType, entry) {
|
|
|
76
79
|
case 'escalation_resolved':
|
|
77
80
|
case 'budget_exceeded_warn':
|
|
78
81
|
return `${prefix}${eventType}`;
|
|
82
|
+
case 'session_continuation': {
|
|
83
|
+
const prev = trimToNull(entry.payload?.previous_run_id);
|
|
84
|
+
const next = trimToNull(entry.payload?.next_run_id);
|
|
85
|
+
const objective = trimToNull(entry.payload?.next_objective);
|
|
86
|
+
if (prev && next) {
|
|
87
|
+
return `${prefix}${eventType} ${prev} -> ${next}${objective ? ` (${objective})` : ''}`;
|
|
88
|
+
}
|
|
89
|
+
return `${prefix}${eventType}`;
|
|
90
|
+
}
|
|
79
91
|
default:
|
|
80
92
|
if (trimToNull(entry.summary)) return entry.summary.trim();
|
|
81
93
|
return `${prefix}${eventType || 'unknown_event'}`;
|
package/src/lib/run-events.js
CHANGED
|
@@ -25,6 +25,9 @@ export const VALID_RUN_EVENTS = [
|
|
|
25
25
|
'acceptance_failed',
|
|
26
26
|
'turn_reissued',
|
|
27
27
|
'turn_stalled',
|
|
28
|
+
'turn_start_failed',
|
|
29
|
+
'runtime_spawn_failed',
|
|
30
|
+
'stdout_attach_failed',
|
|
28
31
|
'turn_checkpointed',
|
|
29
32
|
'coordinator_retry',
|
|
30
33
|
'coordinator_retry_projection_warning',
|
|
@@ -39,6 +42,7 @@ export const VALID_RUN_EVENTS = [
|
|
|
39
42
|
'human_escalation_raised',
|
|
40
43
|
'human_escalation_resolved',
|
|
41
44
|
'dispatch_progress',
|
|
45
|
+
'session_continuation',
|
|
42
46
|
];
|
|
43
47
|
|
|
44
48
|
/**
|