agentxchain 2.144.0 → 2.146.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/release-bump.sh +82 -29
- package/scripts/release-downstream-truth.sh +16 -8
- package/src/commands/init.js +66 -31
- package/src/commands/restart.js +18 -3
- package/src/commands/resume.js +38 -0
- package/src/commands/status.js +37 -3
- package/src/commands/step.js +38 -0
- package/src/lib/config.js +4 -1
- 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/governed-state.js +160 -0
- package/src/lib/intake.js +47 -0
- package/src/lib/intent-startup-migration.js +23 -1
- package/src/lib/recent-event-summary.js +2 -0
- package/src/lib/run-events.js +1 -0
- package/src/lib/run-history.js +23 -2
- package/src/lib/run-loop.js +3 -2
- package/src/lib/stale-turn-watchdog.js +380 -0
- package/src/lib/turn-checkpoint.js +4 -0
package/src/commands/status.js
CHANGED
|
@@ -21,10 +21,11 @@ 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, findPendingApprovedIntents } from '../lib/intake.js';
|
|
24
|
+
import { readPreemptionMarker, validatePreemptionMarker, 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
|
import { readCoordinatorWarnings } from '../lib/coordinator-warnings.js';
|
|
28
|
+
import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
28
29
|
|
|
29
30
|
export async function statusCommand(opts) {
|
|
30
31
|
const context = loadStatusContext();
|
|
@@ -134,7 +135,11 @@ function loadStatusContext(dir = process.cwd()) {
|
|
|
134
135
|
|
|
135
136
|
function renderGovernedStatus(context, opts) {
|
|
136
137
|
const { root, config, version } = context;
|
|
137
|
-
|
|
138
|
+
let state = loadProjectState(root, config);
|
|
139
|
+
const staleReconciliation = reconcileStaleTurns(root, state, config);
|
|
140
|
+
state = staleReconciliation.state || state;
|
|
141
|
+
const staleTurns = staleReconciliation.stale_turns;
|
|
142
|
+
const ghostTurns = staleReconciliation.ghost_turns || [];
|
|
138
143
|
const stateRunId = state?.run_id || readRawStateRunId(root, config);
|
|
139
144
|
const continuity = getContinuityStatus(root, state);
|
|
140
145
|
const connectorHealth = getConnectorHealth(root, config, state);
|
|
@@ -146,7 +151,8 @@ function renderGovernedStatus(context, opts) {
|
|
|
146
151
|
|
|
147
152
|
const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
|
|
148
153
|
const humanEscalation = findCurrentHumanEscalation(root, state);
|
|
149
|
-
|
|
154
|
+
// BUG-48: validate the marker against live intent state; auto-clear stale markers
|
|
155
|
+
const preemptionMarker = validatePreemptionMarker(root);
|
|
150
156
|
const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId || null });
|
|
151
157
|
const continuousSession = readContinuousSession(root);
|
|
152
158
|
const gateActionAttempt = state?.pending_phase_transition
|
|
@@ -201,6 +207,8 @@ function renderGovernedStatus(context, opts) {
|
|
|
201
207
|
binding_drift: detectActiveTurnBindingDrift(state, config),
|
|
202
208
|
bundle_integrity: detectStateBundleDesync(root, state),
|
|
203
209
|
coordinator_warnings: coordinatorWarnings,
|
|
210
|
+
stale_turns: staleTurns,
|
|
211
|
+
ghost_turns: ghostTurns,
|
|
204
212
|
}, null, 2));
|
|
205
213
|
return;
|
|
206
214
|
}
|
|
@@ -445,6 +453,32 @@ function renderGovernedStatus(context, opts) {
|
|
|
445
453
|
}
|
|
446
454
|
}
|
|
447
455
|
|
|
456
|
+
// BUG-51: Ghost turn warning (subprocess never started)
|
|
457
|
+
if (ghostTurns.length > 0) {
|
|
458
|
+
console.log('');
|
|
459
|
+
for (const gt of ghostTurns) {
|
|
460
|
+
const secs = Math.floor(gt.running_ms / 1000);
|
|
461
|
+
console.log(` ${chalk.red.bold('⚠ Ghost turn detected — subprocess never started')}`);
|
|
462
|
+
console.log(` ${chalk.dim('Turn:')} ${gt.turn_id} (${gt.role})`);
|
|
463
|
+
console.log(` ${chalk.dim('Runtime:')} ${gt.runtime_id}`);
|
|
464
|
+
console.log(` ${chalk.dim('Age:')} ${secs}s with no subprocess output`);
|
|
465
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reissue-turn --turn ${gt.turn_id} --reason ghost`)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// BUG-47: Stale turn warning
|
|
470
|
+
if (staleTurns.length > 0) {
|
|
471
|
+
console.log('');
|
|
472
|
+
for (const st of staleTurns) {
|
|
473
|
+
const mins = Math.floor(st.running_ms / 60000);
|
|
474
|
+
console.log(` ${chalk.red.bold('⚠ Stale turn detected')}`);
|
|
475
|
+
console.log(` ${chalk.dim('Turn:')} ${st.turn_id} (${st.role})`);
|
|
476
|
+
console.log(` ${chalk.dim('Runtime:')} ${st.runtime_id}`);
|
|
477
|
+
console.log(` ${chalk.dim('Running:')} ${mins}m with no output`);
|
|
478
|
+
console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reissue-turn --turn ${st.turn_id} --reason stale`)}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
448
482
|
// Queued phase/completion requests
|
|
449
483
|
if (state?.queued_phase_transition) {
|
|
450
484
|
const qt = state.queued_phase_transition;
|
package/src/commands/step.js
CHANGED
|
@@ -70,6 +70,7 @@ import { resolveGovernedRole } from '../lib/role-resolution.js';
|
|
|
70
70
|
import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
|
|
71
71
|
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
72
72
|
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
73
|
+
import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
73
74
|
|
|
74
75
|
export async function stepCommand(opts) {
|
|
75
76
|
const context = loadProjectContext();
|
|
@@ -94,6 +95,17 @@ export async function stepCommand(opts) {
|
|
|
94
95
|
process.exit(1);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
const staleReconciliation = reconcileStaleTurns(root, state, config);
|
|
99
|
+
state = staleReconciliation.state || state;
|
|
100
|
+
if (staleReconciliation.ghost_turns.length > 0) {
|
|
101
|
+
printGhostTurnRecovery(staleReconciliation.ghost_turns);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (staleReconciliation.stale_turns.length > 0) {
|
|
105
|
+
printStaleTurnRecovery(staleReconciliation.stale_turns);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
97
109
|
// Completed runs cannot take more turns
|
|
98
110
|
if (state.status === 'completed') {
|
|
99
111
|
console.log(chalk.green.bold('This run is already completed.'));
|
|
@@ -901,6 +913,32 @@ export async function stepCommand(opts) {
|
|
|
901
913
|
}
|
|
902
914
|
}
|
|
903
915
|
|
|
916
|
+
function printGhostTurnRecovery(ghostTurns) {
|
|
917
|
+
console.log(chalk.red.bold('Ghost turn detected — subprocess never started.'));
|
|
918
|
+
console.log('');
|
|
919
|
+
for (const ghost of ghostTurns) {
|
|
920
|
+
const secs = Math.floor(ghost.running_ms / 1000);
|
|
921
|
+
console.log(` Turn: ${ghost.turn_id} (${ghost.role})`);
|
|
922
|
+
console.log(` Runtime: ${ghost.runtime_id}`);
|
|
923
|
+
console.log(` Age: ${secs}s with no subprocess output`);
|
|
924
|
+
console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${ghost.turn_id} --reason ghost`)}`);
|
|
925
|
+
console.log('');
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function printStaleTurnRecovery(staleTurns) {
|
|
930
|
+
console.log(chalk.red.bold('Stale turn detected.'));
|
|
931
|
+
console.log('');
|
|
932
|
+
for (const stale of staleTurns) {
|
|
933
|
+
const mins = Math.floor(stale.running_ms / 60000);
|
|
934
|
+
console.log(` Turn: ${stale.turn_id} (${stale.role})`);
|
|
935
|
+
console.log(` Runtime: ${stale.runtime_id}`);
|
|
936
|
+
console.log(` Age: ${mins}m with no output`);
|
|
937
|
+
console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${stale.turn_id} --reason stale`)}`);
|
|
938
|
+
console.log('');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
904
942
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
905
943
|
|
|
906
944
|
function loadHookStagedTurn(root, stagingRel) {
|
package/src/lib/config.js
CHANGED
|
@@ -6,6 +6,7 @@ import { safeWriteJson } from './safe-write.js';
|
|
|
6
6
|
import {
|
|
7
7
|
normalizeGovernedStateShape,
|
|
8
8
|
getActiveTurn,
|
|
9
|
+
reconcileApprovalPausesWithConfig,
|
|
9
10
|
reconcileBudgetStatusWithConfig,
|
|
10
11
|
reconcileRecoveryActionsWithConfig,
|
|
11
12
|
} from './governed-state.js';
|
|
@@ -153,11 +154,13 @@ export function loadProjectState(root, config) {
|
|
|
153
154
|
if (config?.protocol_mode === 'governed') {
|
|
154
155
|
const normalized = normalizeGovernedStateShape(stateData);
|
|
155
156
|
stateData = normalized.state;
|
|
157
|
+
const reconciledApprovals = reconcileApprovalPausesWithConfig(stateData, config);
|
|
158
|
+
stateData = reconciledApprovals.state;
|
|
156
159
|
const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
|
|
157
160
|
stateData = reconciledBudget.state;
|
|
158
161
|
const reconciledRecovery = reconcileRecoveryActionsWithConfig(stateData, config);
|
|
159
162
|
stateData = reconciledRecovery.state;
|
|
160
|
-
if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
|
|
163
|
+
if (normalized.changed || reconciledApprovals.changed || reconciledBudget.changed || reconciledRecovery.changed) {
|
|
161
164
|
safeWriteJson(filePath, stateData);
|
|
162
165
|
}
|
|
163
166
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dirname } from 'path';
|
|
2
|
-
import { loadProjectContext } from '../config.js';
|
|
2
|
+
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
3
3
|
import { approvePhaseTransition, approveRunCompletion } from '../governed-state.js';
|
|
4
4
|
import { deriveGovernedRunNextActions, deriveRecoveryDescriptor } from '../blocked-state.js';
|
|
5
5
|
import {
|
|
@@ -205,10 +205,16 @@ function approveCoordinatorGate(workspacePath, state, config) {
|
|
|
205
205
|
|
|
206
206
|
export function approvePendingDashboardGate(agentxchainDir) {
|
|
207
207
|
const workspacePath = dirname(agentxchainDir);
|
|
208
|
-
const
|
|
208
|
+
const context = loadProjectContext(workspacePath);
|
|
209
|
+
|
|
210
|
+
// Use loadProjectState to get reconciled state — approval-pause repair
|
|
211
|
+
// may surface a pending_run_completion from an orphaned blocked_on marker,
|
|
212
|
+
// and we must route on the reconciled truth, not the raw state.json.
|
|
213
|
+
const repoState = (context?.config?.protocol_mode === 'governed'
|
|
214
|
+
? loadProjectState(workspacePath, context.config)
|
|
215
|
+
: null) || readJsonFile(agentxchainDir, 'state.json');
|
|
209
216
|
|
|
210
217
|
if (repoState?.pending_phase_transition || repoState?.pending_run_completion) {
|
|
211
|
-
const context = loadProjectContext(workspacePath);
|
|
212
218
|
return approveRepoGate(workspacePath, context?.config, repoState);
|
|
213
219
|
}
|
|
214
220
|
|
|
@@ -23,6 +23,7 @@ import { readCoordinatorRepoStatusRows } from './coordinator-repo-status.js';
|
|
|
23
23
|
import { readCoordinatorTimeoutStatus } from './coordinator-timeout-status.js';
|
|
24
24
|
import { readAggregatedCoordinatorEvents, watchChildRepoEvents } from './coordinator-event-aggregation.js';
|
|
25
25
|
import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
|
|
26
|
+
import { readNotificationSnapshot } from './notifications-reader.js';
|
|
26
27
|
import { readConnectorHealthSnapshot } from './connectors.js';
|
|
27
28
|
import { readTimeoutStatus } from './timeout-status.js';
|
|
28
29
|
import { queryRunHistory } from '../run-history.js';
|
|
@@ -431,6 +432,16 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
431
432
|
return;
|
|
432
433
|
}
|
|
433
434
|
|
|
435
|
+
if (pathname === '/api/notifications') {
|
|
436
|
+
if (replayMode) {
|
|
437
|
+
writeJson(res, 200, { ok: true, replay_mode: true, message: 'Notification audit is live-only and not available in replay mode.' });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const result = readNotificationSnapshot(workspacePath);
|
|
441
|
+
writeJson(res, result.status, result.body);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
434
445
|
if (pathname === '/api/connectors') {
|
|
435
446
|
const result = readConnectorHealthSnapshot(workspacePath);
|
|
436
447
|
writeJson(res, result.status, result.body);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { loadConfig, loadProjectContext } from '../config.js';
|
|
2
|
+
import { readJsonlFile } from './state-reader.js';
|
|
3
|
+
|
|
4
|
+
function summarizeAuditEntries(entries) {
|
|
5
|
+
const summary = {
|
|
6
|
+
total_attempts: entries.length,
|
|
7
|
+
delivered: 0,
|
|
8
|
+
failed: 0,
|
|
9
|
+
timed_out: 0,
|
|
10
|
+
last_emitted_at: null,
|
|
11
|
+
last_failure_at: null,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (entry?.delivered === true) {
|
|
16
|
+
summary.delivered += 1;
|
|
17
|
+
} else {
|
|
18
|
+
summary.failed += 1;
|
|
19
|
+
if (!summary.last_failure_at || String(entry?.emitted_at || '') > summary.last_failure_at) {
|
|
20
|
+
summary.last_failure_at = entry?.emitted_at || null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (entry?.timed_out === true) {
|
|
24
|
+
summary.timed_out += 1;
|
|
25
|
+
}
|
|
26
|
+
if (!summary.last_emitted_at || String(entry?.emitted_at || '') > summary.last_emitted_at) {
|
|
27
|
+
summary.last_emitted_at = entry?.emitted_at || null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return summary;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeWebhook(webhook) {
|
|
35
|
+
return {
|
|
36
|
+
name: webhook.name,
|
|
37
|
+
timeout_ms: webhook.timeout_ms,
|
|
38
|
+
event_count: Array.isArray(webhook.events) ? webhook.events.length : 0,
|
|
39
|
+
events: Array.isArray(webhook.events) ? webhook.events : [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readNotificationSnapshot(workspacePath) {
|
|
44
|
+
const context = loadProjectContext(workspacePath);
|
|
45
|
+
const governedContext = context?.config ? context : null;
|
|
46
|
+
const legacyConfigResult = governedContext ? null : loadConfig(workspacePath);
|
|
47
|
+
if (!governedContext && !legacyConfigResult) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
status: 404,
|
|
51
|
+
body: {
|
|
52
|
+
ok: false,
|
|
53
|
+
code: 'config_missing',
|
|
54
|
+
error: 'Project config not found. Run `agentxchain init --governed` first.',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const root = governedContext?.root || legacyConfigResult.root;
|
|
60
|
+
const config = governedContext?.config || legacyConfigResult.config;
|
|
61
|
+
const notifications = config?.notifications || {};
|
|
62
|
+
const webhooks = Array.isArray(notifications.webhooks)
|
|
63
|
+
? notifications.webhooks.map(normalizeWebhook)
|
|
64
|
+
: [];
|
|
65
|
+
const configured = webhooks.length > 0;
|
|
66
|
+
const approvalSla = notifications.approval_sla
|
|
67
|
+
? {
|
|
68
|
+
enabled: notifications.approval_sla.enabled !== false,
|
|
69
|
+
reminder_after_seconds: Array.isArray(notifications.approval_sla.reminder_after_seconds)
|
|
70
|
+
? notifications.approval_sla.reminder_after_seconds
|
|
71
|
+
: [],
|
|
72
|
+
}
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
const auditEntries = (readJsonlFile(`${root}/.agentxchain`, 'notification-audit.jsonl') || [])
|
|
76
|
+
.slice()
|
|
77
|
+
.sort((a, b) => String(b?.emitted_at || '').localeCompare(String(a?.emitted_at || '')));
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
status: 200,
|
|
82
|
+
body: {
|
|
83
|
+
ok: true,
|
|
84
|
+
configured,
|
|
85
|
+
webhooks,
|
|
86
|
+
approval_sla: approvalSla,
|
|
87
|
+
summary: summarizeAuditEntries(auditEntries),
|
|
88
|
+
recent: auditEntries.slice(0, 10),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
deriveGovernedRunNextActions,
|
|
13
13
|
deriveRuntimeBlockedGuidance,
|
|
14
14
|
} from '../blocked-state.js';
|
|
15
|
-
import { loadProjectContext } from '../config.js';
|
|
15
|
+
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
16
16
|
import { getContinuityStatus } from '../continuity-status.js';
|
|
17
|
+
import { reconcileStaleTurns } from '../stale-turn-watchdog.js';
|
|
17
18
|
import { readRepoDecisions, summarizeRepoDecisions } from '../repo-decisions.js';
|
|
18
19
|
import { readAllDispatchProgress } from '../dispatch-progress.js';
|
|
19
20
|
|
|
@@ -136,10 +137,21 @@ function enrichGovernedState(agentxchainDir, state) {
|
|
|
136
137
|
return state;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
// Use loadProjectState to get reconciled state (approval-pause repair,
|
|
141
|
+
// budget reconciliation, recovery-action reconciliation applied and
|
|
142
|
+
// persisted to disk). Then apply stale-turn reconciliation so recovery
|
|
143
|
+
// and next-action surfaces reflect the post-watchdog truth — matching
|
|
144
|
+
// the same ordering used by the CLI `status` command.
|
|
145
|
+
let reconciledState = loadProjectState(workspacePath, context.config) || state;
|
|
146
|
+
const staleResult = reconcileStaleTurns(workspacePath, reconciledState, context.config);
|
|
147
|
+
if (staleResult.changed) {
|
|
148
|
+
reconciledState = staleResult.state;
|
|
149
|
+
}
|
|
150
|
+
|
|
139
151
|
return {
|
|
140
|
-
...
|
|
141
|
-
runtime_guidance: deriveRuntimeBlockedGuidance(
|
|
142
|
-
next_actions: deriveGovernedRunNextActions(
|
|
152
|
+
...reconciledState,
|
|
153
|
+
runtime_guidance: deriveRuntimeBlockedGuidance(reconciledState, context.config),
|
|
154
|
+
next_actions: deriveGovernedRunNextActions(reconciledState, context.config),
|
|
143
155
|
dispatch_progress: readAllDispatchProgress(workspacePath),
|
|
144
156
|
};
|
|
145
157
|
}
|
|
@@ -91,8 +91,28 @@ const INTAKE_INTENTS_DIR = '.agentxchain/intake/intents';
|
|
|
91
91
|
const STALE_LOCK_TIMEOUT_MS = 30_000;
|
|
92
92
|
const GOVERNED_SCHEMA_VERSION = '1.1';
|
|
93
93
|
|
|
94
|
+
const PREEMPTION_MARKER_REL = '.agentxchain/intake/injected-priority.json';
|
|
95
|
+
|
|
94
96
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
95
97
|
|
|
98
|
+
/**
|
|
99
|
+
* BUG-48: clear the preemption marker if it references the given intent.
|
|
100
|
+
* Inlined here to avoid a circular dependency with intake.js.
|
|
101
|
+
*/
|
|
102
|
+
function clearPreemptionMarkerIfMatchesIntent(root, intentId) {
|
|
103
|
+
if (!intentId) return;
|
|
104
|
+
const p = join(root, PREEMPTION_MARKER_REL);
|
|
105
|
+
if (!existsSync(p)) return;
|
|
106
|
+
try {
|
|
107
|
+
const marker = JSON.parse(readFileSync(p, 'utf8'));
|
|
108
|
+
if (marker && marker.intent_id === intentId) {
|
|
109
|
+
unlinkSync(p);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// best-effort
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
function generateId(prefix) {
|
|
97
117
|
return `${prefix}_${randomBytes(8).toString('hex')}`;
|
|
98
118
|
}
|
|
@@ -240,6 +260,8 @@ function retireApprovedPhaseScopedIntents(root, state, config, exitedPhase, now)
|
|
|
240
260
|
entered_phase: state?.phase || null,
|
|
241
261
|
});
|
|
242
262
|
safeWriteJson(intentPath, intent);
|
|
263
|
+
// BUG-48: clear preemption marker if it references this now-satisfied intent
|
|
264
|
+
clearPreemptionMarkerIfMatchesIntent(root, intent.intent_id);
|
|
243
265
|
retired.push(intent.intent_id);
|
|
244
266
|
}
|
|
245
267
|
|
|
@@ -1872,6 +1894,137 @@ export function reconcileRecoveryActionsWithConfig(state, config) {
|
|
|
1872
1894
|
return { state: nextState, changed };
|
|
1873
1895
|
}
|
|
1874
1896
|
|
|
1897
|
+
function inferApprovalPauseFromState(state, config) {
|
|
1898
|
+
if (!state || typeof state !== 'object' || !config) {
|
|
1899
|
+
return null;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
if (state.pending_run_completion?.gate) {
|
|
1903
|
+
return {
|
|
1904
|
+
gateType: 'run_completion',
|
|
1905
|
+
gateId: state.pending_run_completion.gate,
|
|
1906
|
+
pendingField: 'pending_run_completion',
|
|
1907
|
+
pendingValue: state.pending_run_completion,
|
|
1908
|
+
typedReason: 'pending_run_completion',
|
|
1909
|
+
recoveryAction: 'agentxchain approve-completion',
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
if (state.pending_phase_transition?.gate) {
|
|
1914
|
+
return {
|
|
1915
|
+
gateType: 'phase_transition',
|
|
1916
|
+
gateId: state.pending_phase_transition.gate,
|
|
1917
|
+
pendingField: 'pending_phase_transition',
|
|
1918
|
+
pendingValue: state.pending_phase_transition,
|
|
1919
|
+
typedReason: 'pending_phase_transition',
|
|
1920
|
+
recoveryAction: 'agentxchain approve-transition',
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Approval waits are post-turn pause states. If a turn is still retained,
|
|
1925
|
+
// recover the turn first instead of synthesizing a gate wait from stale
|
|
1926
|
+
// blocked_on metadata.
|
|
1927
|
+
if (getActiveTurnCount(state) > 0) {
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
if (typeof state.blocked_on !== 'string' || !state.blocked_on.startsWith('human_approval:')) {
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
const gateId = state.blocked_on.slice('human_approval:'.length) || null;
|
|
1936
|
+
const currentRouting = config.routing?.[state.phase];
|
|
1937
|
+
if (!gateId || !currentRouting?.exit_gate || currentRouting.exit_gate !== gateId) {
|
|
1938
|
+
return null;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const requestedByTurn = state.blocked_reason?.turn_id ?? state.last_completed_turn_id ?? null;
|
|
1942
|
+
const nextPhase = getNextPhase(state.phase, config.routing || {});
|
|
1943
|
+
|
|
1944
|
+
if (nextPhase) {
|
|
1945
|
+
return {
|
|
1946
|
+
gateType: 'phase_transition',
|
|
1947
|
+
gateId,
|
|
1948
|
+
pendingField: 'pending_phase_transition',
|
|
1949
|
+
pendingValue: {
|
|
1950
|
+
from: state.phase,
|
|
1951
|
+
to: nextPhase,
|
|
1952
|
+
gate: gateId,
|
|
1953
|
+
requested_by_turn: requestedByTurn,
|
|
1954
|
+
},
|
|
1955
|
+
typedReason: 'pending_phase_transition',
|
|
1956
|
+
recoveryAction: 'agentxchain approve-transition',
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
return {
|
|
1961
|
+
gateType: 'run_completion',
|
|
1962
|
+
gateId,
|
|
1963
|
+
pendingField: 'pending_run_completion',
|
|
1964
|
+
pendingValue: {
|
|
1965
|
+
gate: gateId,
|
|
1966
|
+
requested_by_turn: requestedByTurn,
|
|
1967
|
+
},
|
|
1968
|
+
typedReason: 'pending_run_completion',
|
|
1969
|
+
recoveryAction: 'agentxchain approve-completion',
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
export function reconcileApprovalPausesWithConfig(state, config) {
|
|
1974
|
+
if (!state || typeof state !== 'object' || !config) {
|
|
1975
|
+
return { state, changed: false };
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const inferred = inferApprovalPauseFromState(state, config);
|
|
1979
|
+
if (!inferred) {
|
|
1980
|
+
return { state, changed: false };
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
let nextState = state;
|
|
1984
|
+
let changed = false;
|
|
1985
|
+
|
|
1986
|
+
if (!state[inferred.pendingField]) {
|
|
1987
|
+
nextState = {
|
|
1988
|
+
...nextState,
|
|
1989
|
+
[inferred.pendingField]: inferred.pendingValue,
|
|
1990
|
+
};
|
|
1991
|
+
changed = true;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (nextState.status === 'blocked' || nextState.blocked_reason != null) {
|
|
1995
|
+
nextState = {
|
|
1996
|
+
...nextState,
|
|
1997
|
+
status: 'paused',
|
|
1998
|
+
blocked_reason: null,
|
|
1999
|
+
};
|
|
2000
|
+
changed = true;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
const recovery = nextState.blocked_reason?.recovery;
|
|
2004
|
+
if (recovery && (
|
|
2005
|
+
recovery.typed_reason !== inferred.typedReason
|
|
2006
|
+
|| recovery.recovery_action !== inferred.recoveryAction
|
|
2007
|
+
|| recovery.detail !== inferred.gateId
|
|
2008
|
+
)) {
|
|
2009
|
+
nextState = {
|
|
2010
|
+
...nextState,
|
|
2011
|
+
blocked_reason: {
|
|
2012
|
+
...nextState.blocked_reason,
|
|
2013
|
+
recovery: {
|
|
2014
|
+
...recovery,
|
|
2015
|
+
typed_reason: inferred.typedReason,
|
|
2016
|
+
recovery_action: inferred.recoveryAction,
|
|
2017
|
+
turn_retained: false,
|
|
2018
|
+
detail: inferred.gateId,
|
|
2019
|
+
},
|
|
2020
|
+
},
|
|
2021
|
+
};
|
|
2022
|
+
changed = true;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
return { state: nextState, changed };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1875
2028
|
function inferBlockedReasonFromState(state) {
|
|
1876
2029
|
if (!state || typeof state !== 'object') {
|
|
1877
2030
|
return null;
|
|
@@ -2355,6 +2508,13 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2355
2508
|
repo_decisions: repoDecisions.length > 0 ? repoDecisions : null,
|
|
2356
2509
|
};
|
|
2357
2510
|
|
|
2511
|
+
if ((provenance?.trigger === 'continuation' || provenance?.trigger === 'recovery') && !updatedState.accepted_integration_ref) {
|
|
2512
|
+
const baseline = captureBaseline(root);
|
|
2513
|
+
if (baseline?.head_ref) {
|
|
2514
|
+
updatedState.accepted_integration_ref = `git:${baseline.head_ref}`;
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2358
2518
|
writeState(root, updatedState);
|
|
2359
2519
|
|
|
2360
2520
|
const startupIntents = archiveStaleIntentsForRun(root, runId, {
|
package/src/lib/intake.js
CHANGED
|
@@ -412,6 +412,7 @@ export function triageIntent(root, intentId, fields) {
|
|
|
412
412
|
intent.updated_at = now;
|
|
413
413
|
intent.history.push({ from: 'detected', to: 'suppressed', at: now, reason: fields.reason });
|
|
414
414
|
safeWriteJson(intentPath, intent);
|
|
415
|
+
clearPreemptionMarkerForIntent(root, intentId);
|
|
415
416
|
return { ok: true, intent, exitCode: 0 };
|
|
416
417
|
}
|
|
417
418
|
|
|
@@ -428,6 +429,7 @@ export function triageIntent(root, intentId, fields) {
|
|
|
428
429
|
intent.updated_at = now;
|
|
429
430
|
intent.history.push({ from: 'triaged', to: 'rejected', at: now, reason: fields.reason });
|
|
430
431
|
safeWriteJson(intentPath, intent);
|
|
432
|
+
clearPreemptionMarkerForIntent(root, intentId);
|
|
431
433
|
return { ok: true, intent, exitCode: 0 };
|
|
432
434
|
}
|
|
433
435
|
|
|
@@ -839,6 +841,8 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
839
841
|
intent.archived_reason = phantomReason;
|
|
840
842
|
intent.history.push({ from: previousStatus, to: 'superseded', at: now, reason: phantomReason, approver });
|
|
841
843
|
safeWriteJson(intentPath, intent);
|
|
844
|
+
// BUG-48: clear preemption marker if it references this now-superseded intent
|
|
845
|
+
clearPreemptionMarkerForIntent(root, intentId);
|
|
842
846
|
return { ok: true, intent, superseded: true, exitCode: 0 };
|
|
843
847
|
}
|
|
844
848
|
|
|
@@ -1319,6 +1323,8 @@ export function resolveIntent(root, intentId, opts = {}) {
|
|
|
1319
1323
|
mkdirSync(obsDir, { recursive: true });
|
|
1320
1324
|
|
|
1321
1325
|
safeWriteJson(intentPath, intent);
|
|
1326
|
+
// BUG-48: clear preemption marker if it references this now-completed intent
|
|
1327
|
+
clearPreemptionMarkerForIntent(root, intentId);
|
|
1322
1328
|
return {
|
|
1323
1329
|
ok: true,
|
|
1324
1330
|
intent,
|
|
@@ -1765,6 +1771,47 @@ export function clearPreemptionMarker(root) {
|
|
|
1765
1771
|
}
|
|
1766
1772
|
}
|
|
1767
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* BUG-48: Clear the preemption marker if it references a specific intent.
|
|
1776
|
+
* Called when an intent transitions to a non-actionable terminal state so the
|
|
1777
|
+
* marker cannot outlive the intent it points at.
|
|
1778
|
+
*/
|
|
1779
|
+
export function clearPreemptionMarkerForIntent(root, intentId) {
|
|
1780
|
+
if (!intentId) return;
|
|
1781
|
+
const marker = readPreemptionMarker(root);
|
|
1782
|
+
if (marker && marker.intent_id === intentId) {
|
|
1783
|
+
clearPreemptionMarker(root);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* BUG-48: Validate the preemption marker against the live intent state.
|
|
1789
|
+
* If the marker references an intent whose on-disk status is non-actionable
|
|
1790
|
+
* (superseded, satisfied, completed, rejected, suppressed, failed,
|
|
1791
|
+
* archived_migration), auto-clear the marker and return null.
|
|
1792
|
+
* Otherwise return the marker as-is.
|
|
1793
|
+
*/
|
|
1794
|
+
const PREEMPTION_ACTIONABLE_STATUSES = new Set(['approved', 'planned']);
|
|
1795
|
+
|
|
1796
|
+
export function validatePreemptionMarker(root) {
|
|
1797
|
+
const marker = readPreemptionMarker(root);
|
|
1798
|
+
if (!marker?.intent_id) return marker;
|
|
1799
|
+
|
|
1800
|
+
const loaded = readIntent(root, marker.intent_id);
|
|
1801
|
+
if (!loaded.ok) {
|
|
1802
|
+
// intent file missing — stale marker
|
|
1803
|
+
clearPreemptionMarker(root);
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (!PREEMPTION_ACTIONABLE_STATUSES.has(loaded.intent?.status)) {
|
|
1808
|
+
clearPreemptionMarker(root);
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
return marker;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1768
1815
|
export function consumePreemptionMarker(root, options = {}) {
|
|
1769
1816
|
const marker = readPreemptionMarker(root);
|
|
1770
1817
|
if (!marker?.intent_id) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { queryAcceptedTurnHistory } from './accepted-turn-history.js';
|
|
@@ -6,6 +6,25 @@ import { safeWriteJson } from './safe-write.js';
|
|
|
6
6
|
import { VALID_GOVERNED_TEMPLATE_IDS, loadGovernedTemplate } from './governed-templates.js';
|
|
7
7
|
|
|
8
8
|
const DISPATCHABLE_STATUSES = new Set(['planned', 'approved']);
|
|
9
|
+
const PREEMPTION_MARKER_REL = '.agentxchain/intake/injected-priority.json';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* BUG-48: clear preemption marker if it references the given intent.
|
|
13
|
+
* Inlined to avoid circular dependency with intake.js.
|
|
14
|
+
*/
|
|
15
|
+
function clearPreemptionMarkerIfMatchesIntent(root, intentId) {
|
|
16
|
+
if (!intentId) return;
|
|
17
|
+
const p = join(root, PREEMPTION_MARKER_REL);
|
|
18
|
+
if (!existsSync(p)) return;
|
|
19
|
+
try {
|
|
20
|
+
const marker = JSON.parse(readFileSync(p, 'utf8'));
|
|
21
|
+
if (marker && marker.intent_id === intentId) {
|
|
22
|
+
unlinkSync(p);
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// best-effort
|
|
26
|
+
}
|
|
27
|
+
}
|
|
9
28
|
|
|
10
29
|
function nowISO() {
|
|
11
30
|
return new Date().toISOString();
|
|
@@ -127,6 +146,7 @@ export function migratePreBug34Intents(root, runId, options = {}) {
|
|
|
127
146
|
reason: intent.archived_reason,
|
|
128
147
|
});
|
|
129
148
|
safeWriteJson(intentPath, intent);
|
|
149
|
+
clearPreemptionMarkerIfMatchesIntent(root, intent.intent_id);
|
|
130
150
|
if (intent.intent_id) archivedMigrationIntentIds.push(intent.intent_id);
|
|
131
151
|
}
|
|
132
152
|
|
|
@@ -260,6 +280,8 @@ export function archiveStaleIntentsForRun(root, runId, options = {}) {
|
|
|
260
280
|
reason: intent.archived_reason,
|
|
261
281
|
});
|
|
262
282
|
safeWriteJson(intentPath, intent);
|
|
283
|
+
// BUG-48: clear preemption marker if it references this now-superseded intent
|
|
284
|
+
clearPreemptionMarkerIfMatchesIntent(root, intent.intent_id);
|
|
263
285
|
phantomSuperseded += 1;
|
|
264
286
|
if (intent.intent_id) phantomSupersededIntentIds.push(intent.intent_id);
|
|
265
287
|
continue;
|
|
@@ -53,6 +53,8 @@ function describeEvent(eventType, entry) {
|
|
|
53
53
|
return `${prefix}${eventType}${wsIdWarn ? ` ${wsIdWarn}` : ''}${warnRepo ? ` (${warnRepo})` : ''} — reconciliation required`;
|
|
54
54
|
}
|
|
55
55
|
case 'turn_checkpointed':
|
|
56
|
+
case 'turn_stalled':
|
|
57
|
+
case 'turn_start_failed':
|
|
56
58
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|
|
57
59
|
case 'dispatch_progress':
|
|
58
60
|
return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
|