agentxchain 2.144.0 → 2.145.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.144.0",
3
+ "version": "2.145.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,6 +69,8 @@ echo "AgentXchain Release Identity: ${TARGET_VERSION}"
69
69
  echo "============================================="
70
70
 
71
71
  TARGET_RELEASE_DOC="website-v2/docs/releases/v${TARGET_VERSION//./-}.mdx"
72
+ CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
73
+ REENTRY_MODE=0
72
74
  ALLOWED_RELEASE_PATHS=(
73
75
  "cli/CHANGELOG.md"
74
76
  "${TARGET_RELEASE_DOC}"
@@ -89,6 +91,10 @@ ALLOWED_RELEASE_PATHS=(
89
91
  "cli/homebrew/agentxchain.rb"
90
92
  "cli/homebrew/README.md"
91
93
  )
94
+ ALLOWED_REENTRY_VERSION_PATHS=(
95
+ "cli/package.json"
96
+ "cli/package-lock.json"
97
+ )
92
98
 
93
99
  is_allowed_release_path() {
94
100
  local candidate="$1"
@@ -112,15 +118,32 @@ stage_if_present() {
112
118
  fi
113
119
  }
114
120
 
115
- # 1. Assert only allowed release-surface dirt is present
116
- echo "[1/8] Checking release-prep tree state..."
121
+ # 1. Detect version/re-entry state before validating the tree
122
+ echo "[1/10] Checking current version..."
123
+ if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
124
+ REENTRY_MODE=1
125
+ echo " OK: package.json already targets ${TARGET_VERSION}; entering release re-entry mode"
126
+ else
127
+ echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
128
+ fi
129
+
130
+ # 2. Assert only allowed release-surface dirt is present
131
+ echo "[2/10] Checking release-prep tree state..."
117
132
  DISALLOWED_DIRTY=()
118
133
  while IFS= read -r status_line; do
119
134
  [[ -z "$status_line" ]] && continue
120
135
  path="${status_line#?? }"
121
- if ! is_allowed_release_path "$path"; then
122
- DISALLOWED_DIRTY+=("$path")
136
+ if is_allowed_release_path "$path"; then
137
+ continue
138
+ fi
139
+ if [[ "$REENTRY_MODE" -eq 1 ]]; then
140
+ for allowed in "${ALLOWED_REENTRY_VERSION_PATHS[@]}"; do
141
+ if [[ "$path" == "$allowed" ]]; then
142
+ continue 2
143
+ fi
144
+ done
123
145
  fi
146
+ DISALLOWED_DIRTY+=("$path")
124
147
  done < <(git -C "$REPO_ROOT" status --porcelain)
125
148
 
126
149
  if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
@@ -130,17 +153,8 @@ if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
130
153
  fi
131
154
  echo " OK: tree contains only allowed release-prep changes"
132
155
 
133
- # 2. Assert not already at target version
134
- echo "[2/8] Checking current version..."
135
- CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
136
- if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
137
- echo "FAIL: package.json is already at ${TARGET_VERSION}. Cannot double-bump." >&2
138
- exit 1
139
- fi
140
- echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
141
-
142
156
  # 3. Assert tag does not already exist
143
- echo "[3/8] Checking for existing tag..."
157
+ echo "[3/10] Checking for existing tag..."
144
158
  if git rev-parse "v${TARGET_VERSION}" >/dev/null 2>&1; then
145
159
  echo "FAIL: tag v${TARGET_VERSION} already exists. Delete it first or choose a different version." >&2
146
160
  exit 1
@@ -236,7 +250,11 @@ fi
236
250
 
237
251
  # 7. Update version files (no git operations)
238
252
  echo "[7/10] Updating version files..."
239
- npm version "$TARGET_VERSION" --no-git-tag-version
253
+ if [[ "$REENTRY_MODE" -eq 1 ]]; then
254
+ npm version "$TARGET_VERSION" --no-git-tag-version --allow-same-version
255
+ else
256
+ npm version "$TARGET_VERSION" --no-git-tag-version
257
+ fi
240
258
  echo " OK: package.json updated to ${TARGET_VERSION}"
241
259
 
242
260
  # 8. Stage version files
@@ -251,23 +269,58 @@ done
251
269
  git -C "$REPO_ROOT" add -- website-v2/docs/releases
252
270
  echo " OK: version files and allowed release surfaces staged"
253
271
 
254
- # 9. Create release commit
255
- echo "[9/10] Creating release commit..."
256
- git commit -m "${TARGET_VERSION}
272
+ # 9. Create or reuse release commit
273
+ if git diff --cached --quiet --exit-code; then
274
+ CURRENT_HEAD_SHA=$(git rev-parse HEAD)
275
+ echo "[9/10] Resolving re-entry release identity..."
276
+ COMMIT_MSG=$(git log -1 --format=%s)
277
+ if [[ "$COMMIT_MSG" == "$TARGET_VERSION" ]]; then
278
+ COMMIT_BODY=$(git log -1 --format=%B)
279
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
280
+ echo "FAIL: existing HEAD commit for re-entry is missing the required Co-Authored-By trailer" >&2
281
+ exit 1
282
+ fi
283
+ RELEASE_SHA=$(git rev-parse HEAD)
284
+ echo " OK: reusing existing release commit ${RELEASE_SHA:0:7}"
285
+ elif [[ "$REENTRY_MODE" -eq 1 ]]; then
286
+ echo " No staged release-surface deltas remain; creating metadata-only release identity commit for ${CURRENT_HEAD_SHA:0:7}"
287
+ git commit --allow-empty -m "${TARGET_VERSION}
288
+
289
+ Release-Base: ${CURRENT_HEAD_SHA}
290
+ Co-Authored-By: ${COAUTHORED_BY}"
291
+ RELEASE_SHA=$(git rev-parse HEAD)
292
+ COMMIT_BODY=$(git log -1 --format=%B)
293
+ if [[ "$COMMIT_BODY" != *"Release-Base: ${CURRENT_HEAD_SHA}"* ]]; then
294
+ echo "FAIL: metadata-only release identity commit is missing the required Release-Base line" >&2
295
+ exit 1
296
+ fi
297
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
298
+ echo "FAIL: metadata-only release identity commit is missing the required Co-Authored-By trailer" >&2
299
+ exit 1
300
+ fi
301
+ echo " OK: metadata-only release identity commit ${RELEASE_SHA:0:7} recorded base ${CURRENT_HEAD_SHA:0:7}"
302
+ else
303
+ echo "FAIL: no staged release-identity changes remain, and HEAD is not already the ${TARGET_VERSION} release commit. Found commit message '${COMMIT_MSG}'." >&2
304
+ exit 1
305
+ fi
306
+ else
307
+ echo "[9/10] Creating release commit..."
308
+ git commit -m "${TARGET_VERSION}
257
309
 
258
310
  Co-Authored-By: ${COAUTHORED_BY}"
259
- RELEASE_SHA=$(git rev-parse HEAD)
260
- COMMIT_MSG=$(git log -1 --format=%s)
261
- if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
262
- echo "FAIL: commit message is '${COMMIT_MSG}', expected '${TARGET_VERSION}'" >&2
263
- exit 1
264
- fi
265
- COMMIT_BODY=$(git log -1 --format=%B)
266
- if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
267
- echo "FAIL: release commit body is missing the required Co-Authored-By trailer" >&2
268
- exit 1
311
+ RELEASE_SHA=$(git rev-parse HEAD)
312
+ COMMIT_MSG=$(git log -1 --format=%s)
313
+ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
314
+ echo "FAIL: commit message is '${COMMIT_MSG}', expected '${TARGET_VERSION}'" >&2
315
+ exit 1
316
+ fi
317
+ COMMIT_BODY=$(git log -1 --format=%B)
318
+ if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
319
+ echo "FAIL: release commit body is missing the required Co-Authored-By trailer" >&2
320
+ exit 1
321
+ fi
322
+ echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
269
323
  fi
270
- echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
271
324
 
272
325
  # 9.5. Inline preflight gate — tests, pack, and docs build must pass before tag
273
326
  if [[ "$SKIP_PREFLIGHT" -eq 1 ]]; then
@@ -40,6 +40,7 @@ import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
40
40
  import { runHooks } from '../lib/hook-runner.js';
41
41
  import { summarizeRunProvenance } from '../lib/run-provenance.js';
42
42
  import { consumeNextApprovedIntent } from '../lib/intake.js';
43
+ import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
43
44
 
44
45
  export async function resumeCommand(opts) {
45
46
  const context = loadProjectContext();
@@ -75,6 +76,13 @@ export async function resumeCommand(opts) {
75
76
  process.exit(1);
76
77
  }
77
78
 
79
+ const staleReconciliation = reconcileStaleTurns(root, state, config);
80
+ state = staleReconciliation.state || state;
81
+ if (staleReconciliation.stale_turns.length > 0) {
82
+ printStaleTurnRecovery(staleReconciliation.stale_turns);
83
+ process.exit(1);
84
+ }
85
+
78
86
  // §47: active + turns present → reject (resume assigns new turns, not re-dispatches)
79
87
  const activeCount = getActiveTurnCount(state);
80
88
  const activeTurns = getActiveTurns(state);
@@ -351,6 +359,19 @@ export async function resumeCommand(opts) {
351
359
  printDispatchSummary(state, config);
352
360
  }
353
361
 
362
+ function printStaleTurnRecovery(staleTurns) {
363
+ console.log(chalk.red.bold('Stale turn detected.'));
364
+ console.log('');
365
+ for (const stale of staleTurns) {
366
+ const mins = Math.floor(stale.running_ms / 60000);
367
+ console.log(` Turn: ${stale.turn_id} (${stale.role})`);
368
+ console.log(` Runtime: ${stale.runtime_id}`);
369
+ console.log(` Age: ${mins}m with no output`);
370
+ console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${stale.turn_id} --reason stale`)}`);
371
+ console.log('');
372
+ }
373
+ }
374
+
354
375
  // ── Helpers ─────────────────────────────────────────────────────────────────
355
376
 
356
377
  function printResumeRunContext({ root, state, config }) {
@@ -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,7 @@ 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);
138
139
  const stateRunId = state?.run_id || readRawStateRunId(root, config);
139
140
  const continuity = getContinuityStatus(root, state);
140
141
  const connectorHealth = getConnectorHealth(root, config, state);
@@ -146,7 +147,8 @@ function renderGovernedStatus(context, opts) {
146
147
 
147
148
  const workflowKitArtifacts = deriveWorkflowKitArtifacts(root, config, state);
148
149
  const humanEscalation = findCurrentHumanEscalation(root, state);
149
- const preemptionMarker = readPreemptionMarker(root);
150
+ // BUG-48: validate the marker against live intent state; auto-clear stale markers
151
+ const preemptionMarker = validatePreemptionMarker(root);
150
152
  const pendingIntents = findPendingApprovedIntents(root, { run_id: stateRunId || null });
151
153
  const continuousSession = readContinuousSession(root);
152
154
  const gateActionAttempt = state?.pending_phase_transition
@@ -164,6 +166,11 @@ function renderGovernedStatus(context, opts) {
164
166
  // Coordinator warning surfacing — DEC-COORD-RETRY-PROJECTION-EVENT-001
165
167
  const coordinatorWarnings = readCoordinatorWarnings(root, { runId: stateRunId || null });
166
168
 
169
+ // BUG-47: detect stale running turns and emit turn_stalled events
170
+ const staleReconciliation = reconcileStaleTurns(root, state, config);
171
+ state = staleReconciliation.state || state;
172
+ const staleTurns = staleReconciliation.stale_turns;
173
+
167
174
  if (opts.json) {
168
175
  const dashPid = getDashboardPid(root);
169
176
  const dashSession = getDashboardSession(root);
@@ -201,6 +208,7 @@ function renderGovernedStatus(context, opts) {
201
208
  binding_drift: detectActiveTurnBindingDrift(state, config),
202
209
  bundle_integrity: detectStateBundleDesync(root, state),
203
210
  coordinator_warnings: coordinatorWarnings,
211
+ stale_turns: staleTurns,
204
212
  }, null, 2));
205
213
  return;
206
214
  }
@@ -445,6 +453,19 @@ function renderGovernedStatus(context, opts) {
445
453
  }
446
454
  }
447
455
 
456
+ // BUG-47: Stale turn warning
457
+ if (staleTurns.length > 0) {
458
+ console.log('');
459
+ for (const st of staleTurns) {
460
+ const mins = Math.floor(st.running_ms / 60000);
461
+ console.log(` ${chalk.red.bold('⚠ Stale turn detected')}`);
462
+ console.log(` ${chalk.dim('Turn:')} ${st.turn_id} (${st.role})`);
463
+ console.log(` ${chalk.dim('Runtime:')} ${st.runtime_id}`);
464
+ console.log(` ${chalk.dim('Running:')} ${mins}m with no output`);
465
+ console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(`agentxchain reissue-turn --turn ${st.turn_id} --reason stale`)}`);
466
+ }
467
+ }
468
+
448
469
  // Queued phase/completion requests
449
470
  if (state?.queued_phase_transition) {
450
471
  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,13 @@ 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.stale_turns.length > 0) {
101
+ printStaleTurnRecovery(staleReconciliation.stale_turns);
102
+ process.exit(1);
103
+ }
104
+
97
105
  // Completed runs cannot take more turns
98
106
  if (state.status === 'completed') {
99
107
  console.log(chalk.green.bold('This run is already completed.'));
@@ -901,6 +909,19 @@ export async function stepCommand(opts) {
901
909
  }
902
910
  }
903
911
 
912
+ function printStaleTurnRecovery(staleTurns) {
913
+ console.log(chalk.red.bold('Stale turn detected.'));
914
+ console.log('');
915
+ for (const stale of staleTurns) {
916
+ const mins = Math.floor(stale.running_ms / 60000);
917
+ console.log(` Turn: ${stale.turn_id} (${stale.role})`);
918
+ console.log(` Runtime: ${stale.runtime_id}`);
919
+ console.log(` Age: ${mins}m with no output`);
920
+ console.log(` Recover: ${chalk.cyan(`agentxchain reissue-turn --turn ${stale.turn_id} --reason stale`)}`);
921
+ console.log('');
922
+ }
923
+ }
924
+
904
925
  // ── Helpers ─────────────────────────────────────────────────────────────────
905
926
 
906
927
  function loadHookStagedTurn(root, stagingRel) {
@@ -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
 
@@ -2355,6 +2377,13 @@ export function initializeGovernedRun(root, config, options = {}) {
2355
2377
  repo_decisions: repoDecisions.length > 0 ? repoDecisions : null,
2356
2378
  };
2357
2379
 
2380
+ if ((provenance?.trigger === 'continuation' || provenance?.trigger === 'recovery') && !updatedState.accepted_integration_ref) {
2381
+ const baseline = captureBaseline(root);
2382
+ if (baseline?.head_ref) {
2383
+ updatedState.accepted_integration_ref = `git:${baseline.head_ref}`;
2384
+ }
2385
+ }
2386
+
2358
2387
  writeState(root, updatedState);
2359
2388
 
2360
2389
  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,7 @@ 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':
56
57
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
57
58
  case 'dispatch_progress':
58
59
  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',
@@ -42,10 +42,18 @@ export function recordRunHistory(root, state, config, status) {
42
42
  const filePath = join(root, RUN_HISTORY_PATH);
43
43
  mkdirSync(dirname(filePath), { recursive: true });
44
44
 
45
- const historyEntries = readJsonlSafe(root, HISTORY_PATH);
45
+ const allHistoryEntries = readJsonlSafe(root, HISTORY_PATH);
46
46
  const ledgerEntries = readJsonlSafe(root, LEDGER_PATH);
47
47
 
48
- // Extract unique phases and roles from turn history
48
+ // BUG-50: filter history entries to the current run only.
49
+ // history.jsonl accumulates across runs; using all entries causes fresh
50
+ // run records to inherit parent run phases_completed/total_turns.
51
+ const currentRunId = state?.run_id || null;
52
+ const historyEntries = currentRunId
53
+ ? allHistoryEntries.filter(e => e.run_id === currentRunId)
54
+ : allHistoryEntries;
55
+
56
+ // Extract unique phases and roles from THIS run's turn history only
49
57
  const phasesCompleted = [...new Set(historyEntries.map(e => e.phase).filter(Boolean))];
50
58
  const rolesUsed = [...new Set(historyEntries.map(e => e.role).filter(Boolean))];
51
59
 
@@ -84,6 +92,7 @@ export function recordRunHistory(root, state, config, status) {
84
92
  connector_used: connectorUsed,
85
93
  model_used: modelUsed,
86
94
  provenance: normalizeRunProvenance(state?.provenance),
95
+ parent_context: buildParentContextSummary(state),
87
96
  retrospective: buildRunRetrospective({
88
97
  state,
89
98
  config,
@@ -317,6 +326,18 @@ function buildRecentAcceptedTurnSnapshot(entries) {
317
326
  }));
318
327
  }
319
328
 
329
+ function buildParentContextSummary(state) {
330
+ const parentRunId = state?.provenance?.parent_run_id || state?.inherited_context?.parent_run_id || null;
331
+ if (!parentRunId) return null;
332
+
333
+ return {
334
+ parent_run_id: parentRunId,
335
+ parent_status: state?.inherited_context?.parent_status || null,
336
+ parent_completed_at: state?.inherited_context?.parent_completed_at || null,
337
+ inherited_at: state?.inherited_context?.inherited_at || null,
338
+ };
339
+ }
340
+
320
341
  function buildRunRetrospective({ state, config, status, historyEntries }) {
321
342
  const acceptedTurns = historyEntries.filter((entry) => entry && typeof entry === 'object');
322
343
  const lastAcceptedTurn = acceptedTurns[acceptedTurns.length - 1] || null;
@@ -38,7 +38,7 @@ import { runAdmissionControl } from './admission-control.js';
38
38
  import { appendFileSync, mkdirSync, writeFileSync } from 'fs';
39
39
  import { join, dirname } from 'path';
40
40
  import { evaluateApprovalSlaReminders } from './notification-runner.js';
41
- import { readPreemptionMarker } from './intake.js';
41
+ import { validatePreemptionMarker } from './intake.js';
42
42
  import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
43
43
 
44
44
  const DEFAULT_MAX_TURNS = 50;
@@ -139,7 +139,8 @@ export async function runLoop(root, config, callbacks, options = {}) {
139
139
  // interruption).
140
140
  const activeTurnCount = getActiveTurnCount(state);
141
141
  if (activeTurnCount === 0) {
142
- const marker = readPreemptionMarker(root);
142
+ // BUG-48: validate marker against live intent state before preempting
143
+ const marker = validatePreemptionMarker(root);
143
144
  if (marker && marker.priority === 'p0') {
144
145
  emit({ type: 'priority_injected', intent_id: marker.intent_id, priority: marker.priority });
145
146
  const result = makeResult(false, 'priority_preempted', state, turnsExecuted, turnHistory, gatesApproved, errors);
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Stale Turn Watchdog — BUG-47
3
+ *
4
+ * Lazy idle-threshold detection: if an active turn has status "running"
5
+ * for >N seconds with no event log activity AND no staged result file,
6
+ * report it as stalled.
7
+ *
8
+ * Fires on CLI invocations (status, resume, step --resume) rather than
9
+ * requiring a background daemon.
10
+ *
11
+ * Default thresholds:
12
+ * - local_cli turns: 10 minutes
13
+ * - api_proxy turns: 5 minutes
14
+ * - Configurable via run_loop.stale_turn_threshold_ms in agentxchain.json
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { safeWriteJson } from './safe-write.js';
20
+ import { emitRunEvent, readRunEvents } from './run-events.js';
21
+ import { getTurnStagingResultPath } from './turn-paths.js';
22
+ import { getDispatchProgressRelativePath } from './dispatch-progress.js';
23
+
24
+ const DEFAULT_LOCAL_CLI_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
25
+ const DEFAULT_API_PROXY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
26
+ const LEGACY_STAGING_PATH = '.agentxchain/staging/turn-result.json';
27
+
28
+ /**
29
+ * Check all active turns for stale "running" status.
30
+ *
31
+ * @param {string} root - project root directory
32
+ * @param {object} state - current governed state
33
+ * @param {object} config - normalized config
34
+ * @returns {Array<{ turn_id: string, role: string, runtime_id: string, running_ms: number, threshold_ms: number, recommendation: string }>}
35
+ */
36
+ export function detectStaleTurns(root, state, config) {
37
+ const activeTurns = state?.active_turns || {};
38
+ const stale = [];
39
+ const now = Date.now();
40
+
41
+ for (const [turnId, turn] of Object.entries(activeTurns)) {
42
+ if (turn.status !== 'running' && turn.status !== 'retrying') continue;
43
+ if (!turn.started_at) continue;
44
+
45
+ const startedAt = new Date(turn.started_at).getTime();
46
+ if (isNaN(startedAt)) continue;
47
+
48
+ const runningMs = now - startedAt;
49
+ const threshold = resolveThreshold(turn, config);
50
+
51
+ if (runningMs < threshold) continue;
52
+
53
+ if (hasTurnScopedStagedResult(root, turnId)) continue;
54
+
55
+ const progressPath = join(root, getDispatchProgressRelativePath(turnId));
56
+ if (existsSync(progressPath)) {
57
+ try {
58
+ const progress = JSON.parse(readFileSync(progressPath, 'utf8'));
59
+ const lastActivity = progress.last_activity_at
60
+ ? new Date(progress.last_activity_at).getTime()
61
+ : 0;
62
+ // If there was activity within the threshold, not stale
63
+ if (lastActivity > 0 && (now - lastActivity) < threshold) continue;
64
+ } catch {
65
+ // ignore parse errors
66
+ }
67
+ }
68
+
69
+ if (hasRecentTurnEventActivity(root, turnId, startedAt, threshold, now)) continue;
70
+
71
+ const runningMinutes = Math.floor(runningMs / 60000);
72
+ stale.push({
73
+ turn_id: turnId,
74
+ role: turn.assigned_role || 'unknown',
75
+ runtime_id: turn.runtime_id || 'unknown',
76
+ running_ms: runningMs,
77
+ threshold_ms: threshold,
78
+ recommendation: `Turn ${turnId} has been running for ${runningMinutes}m with no output. `
79
+ + `Run \`agentxchain reissue-turn --turn ${turnId} --reason stale\` to recover.`,
80
+ });
81
+ }
82
+
83
+ return stale;
84
+ }
85
+
86
+ /**
87
+ * Detect stale turns and emit turn_stalled events for each.
88
+ * Returns the stale turn list for caller display.
89
+ */
90
+ export function detectAndEmitStaleTurns(root, state, config) {
91
+ return reconcileStaleTurns(root, state, config).stale_turns;
92
+ }
93
+
94
+ // ── Internal ────────────────────────────────────────────────────────────────
95
+
96
+ export function reconcileStaleTurns(root, state, config) {
97
+ if (!state || typeof state !== 'object') {
98
+ return { stale_turns: [], state, changed: false };
99
+ }
100
+
101
+ const stale = detectStaleTurns(root, state, config);
102
+ if (stale.length === 0) {
103
+ return { stale_turns: [], state, changed: false };
104
+ }
105
+
106
+ const nowIso = new Date().toISOString();
107
+ const activeTurns = { ...(state.active_turns || {}) };
108
+ let changed = false;
109
+
110
+ for (const entry of stale) {
111
+ const turn = activeTurns[entry.turn_id];
112
+ if (!turn || (turn.status !== 'running' && turn.status !== 'retrying')) continue;
113
+
114
+ activeTurns[entry.turn_id] = {
115
+ ...turn,
116
+ status: 'stalled',
117
+ stalled_at: nowIso,
118
+ stalled_reason: 'no_output_within_threshold',
119
+ stalled_previous_status: turn.status,
120
+ stalled_threshold_ms: entry.threshold_ms,
121
+ stalled_running_ms: entry.running_ms,
122
+ recovery_command: `agentxchain reissue-turn --turn ${entry.turn_id} --reason stale`,
123
+ };
124
+ changed = true;
125
+
126
+ emitRunEvent(root, 'turn_stalled', {
127
+ run_id: state?.run_id || null,
128
+ phase: state?.phase || null,
129
+ status: 'blocked',
130
+ turn: { turn_id: entry.turn_id, role_id: entry.role },
131
+ payload: {
132
+ running_ms: entry.running_ms,
133
+ threshold_ms: entry.threshold_ms,
134
+ runtime_id: entry.runtime_id,
135
+ recommendation: entry.recommendation,
136
+ },
137
+ });
138
+ }
139
+
140
+ if (!changed) {
141
+ return { stale_turns: stale, state, changed: false };
142
+ }
143
+
144
+ const primary = stale[0];
145
+ const nextState = {
146
+ ...state,
147
+ status: 'blocked',
148
+ active_turns: activeTurns,
149
+ blocked_on: stale.length === 1 ? `turn:stalled:${primary.turn_id}` : 'turns:stalled',
150
+ blocked_reason: {
151
+ category: 'stale_turn',
152
+ blocked_at: nowIso,
153
+ turn_id: primary.turn_id,
154
+ recovery: {
155
+ typed_reason: 'stale_turn',
156
+ owner: 'human',
157
+ recovery_action: primary.recommendation,
158
+ turn_retained: true,
159
+ detail: primary.recommendation,
160
+ },
161
+ },
162
+ };
163
+
164
+ safeWriteJson(join(root, '.agentxchain', 'state.json'), nextState);
165
+ emitRunEvent(root, 'run_blocked', {
166
+ run_id: nextState.run_id || null,
167
+ phase: nextState.phase || null,
168
+ status: 'blocked',
169
+ turn: { turn_id: primary.turn_id, role_id: primary.role },
170
+ payload: {
171
+ category: 'stale_turn',
172
+ stalled_turn_ids: stale.map((entry) => entry.turn_id),
173
+ },
174
+ });
175
+ return { stale_turns: stale, state: nextState, changed: true };
176
+ }
177
+
178
+ function resolveThreshold(turn, config) {
179
+ // Config override takes precedence
180
+ const configThreshold = config?.run_loop?.stale_turn_threshold_ms;
181
+ if (typeof configThreshold === 'number' && configThreshold > 0) {
182
+ return configThreshold;
183
+ }
184
+
185
+ // Runtime-type-based defaults
186
+ const runtimeId = turn.runtime_id || '';
187
+ const runtimeConfig = config?.runtimes?.[runtimeId];
188
+ const runtimeType = runtimeConfig?.type || '';
189
+
190
+ if (runtimeType === 'api_proxy') {
191
+ return DEFAULT_API_PROXY_THRESHOLD_MS;
192
+ }
193
+
194
+ return DEFAULT_LOCAL_CLI_THRESHOLD_MS;
195
+ }
196
+
197
+ function hasRecentTurnEventActivity(root, turnId, startedAt, threshold, now) {
198
+ try {
199
+ const events = readRunEvents(root, { limit: 200 });
200
+ for (let i = events.length - 1; i >= 0; i--) {
201
+ const event = events[i];
202
+ if (event?.turn?.turn_id !== turnId) continue;
203
+ if (event.event_type === 'turn_stalled') continue;
204
+ const timestamp = Date.parse(event.timestamp || '');
205
+ if (!Number.isFinite(timestamp)) continue;
206
+ if (timestamp < startedAt) continue;
207
+ if ((now - timestamp) < threshold) {
208
+ return true;
209
+ }
210
+ }
211
+ } catch {
212
+ return false;
213
+ }
214
+ return false;
215
+ }
216
+
217
+ function hasTurnScopedStagedResult(root, turnId) {
218
+ const turnScopedPath = join(root, getTurnStagingResultPath(turnId));
219
+ if (existsSync(turnScopedPath)) {
220
+ return true;
221
+ }
222
+
223
+ const legacyPath = join(root, LEGACY_STAGING_PATH);
224
+ if (!existsSync(legacyPath)) {
225
+ return false;
226
+ }
227
+
228
+ try {
229
+ const parsed = JSON.parse(readFileSync(legacyPath, 'utf8'));
230
+ return parsed?.turn_id === turnId;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
@@ -267,6 +267,10 @@ export function checkpointAcceptedTurn(root, opts = {}) {
267
267
  if (state) {
268
268
  writeState(root, {
269
269
  ...state,
270
+ // BUG-49: advance accepted_integration_ref to the new checkpoint SHA
271
+ // so drift detection compares against the current checkpoint, not a
272
+ // stale ref from the parent run or the pre-checkpoint state.
273
+ accepted_integration_ref: `git:${checkpointSha}`,
270
274
  last_completed_turn: {
271
275
  turn_id: entry.turn_id,
272
276
  role: entry.role || null,