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.
@@ -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
- const state = loadProjectState(root, config);
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
- const preemptionMarker = readPreemptionMarker(root);
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;
@@ -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 repoState = readJsonFile(agentxchainDir, 'state.json');
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
- ...state,
141
- runtime_guidance: deriveRuntimeBlockedGuidance(state, context.config),
142
- next_actions: deriveGovernedRunNextActions(state, context.config),
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}]` : ''}`;
@@ -24,6 +24,7 @@ export const VALID_RUN_EVENTS = [
24
24
  'conflict_resolved',
25
25
  'acceptance_failed',
26
26
  'turn_reissued',
27
+ 'turn_stalled',
27
28
  'turn_checkpointed',
28
29
  'coordinator_retry',
29
30
  'coordinator_retry_projection_warning',