agentxchain 2.148.0 → 2.149.1

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.
@@ -0,0 +1,21 @@
1
+ const TURN_RUNNING_PROOF_STREAMS = new Set(['stdout', 'request', 'staged_result']);
2
+
3
+ export function isKnownTurnRunningProofStream(stream) {
4
+ return typeof stream === 'string' && TURN_RUNNING_PROOF_STREAMS.has(stream);
5
+ }
6
+
7
+ export function isPersistedTurnStartupProofStream(stream) {
8
+ if (stream == null) {
9
+ // Legacy states may have first_output_at without a tagged stream.
10
+ return true;
11
+ }
12
+ return isKnownTurnRunningProofStream(stream);
13
+ }
14
+
15
+ export function isDispatchProgressProofOutputStream(stream) {
16
+ return stream === 'stdout';
17
+ }
18
+
19
+ export function isDispatchProgressDiagnosticStream(stream) {
20
+ return stream === 'stderr';
21
+ }
@@ -77,6 +77,7 @@ import {
77
77
  derivePhaseScopeFromIntentMetadata,
78
78
  evaluateAcceptanceItemLifecycle,
79
79
  } from './intent-phase-scope.js';
80
+ import { isKnownTurnRunningProofStream } from './dispatch-streams.js';
80
81
 
81
82
  // ── Constants ────────────────────────────────────────────────────────────────
82
83
 
@@ -995,8 +996,10 @@ export function transitionActiveTurnLifecycle(root, turnId, nextStatus, options
995
996
  } else if (nextStatus === 'running') {
996
997
  nextTurn.status = 'running';
997
998
  nextTurn.started_at = nextTurn.started_at || nowIso;
998
- nextTurn.first_output_at = nextTurn.first_output_at || nowIso;
999
- if (options.stream) {
999
+ if (options.stream == null) {
1000
+ nextTurn.first_output_at = nextTurn.first_output_at || nowIso;
1001
+ } else if (isKnownTurnRunningProofStream(options.stream)) {
1002
+ nextTurn.first_output_at = nextTurn.first_output_at || nowIso;
1000
1003
  nextTurn.first_output_stream = nextTurn.first_output_stream || options.stream;
1001
1004
  }
1002
1005
  } else {
@@ -1531,6 +1534,68 @@ function findHistoryTurnRequest(historyEntries, turnId, kind) {
1531
1534
  return entry;
1532
1535
  }
1533
1536
 
1537
+ function findMatchingPhaseTransitionDeclarer(historyEntries, gateFailure) {
1538
+ if (!Array.isArray(historyEntries) || historyEntries.length === 0) {
1539
+ return null;
1540
+ }
1541
+
1542
+ const targetPhase = typeof gateFailure?.to_phase === 'string' && gateFailure.to_phase.length > 0
1543
+ ? gateFailure.to_phase
1544
+ : null;
1545
+ const sourcePhase = typeof gateFailure?.from_phase === 'string' && gateFailure.from_phase.length > 0
1546
+ ? gateFailure.from_phase
1547
+ : null;
1548
+
1549
+ return [...historyEntries].reverse().find((entry) => {
1550
+ if (!entry?.phase_transition_request) {
1551
+ return false;
1552
+ }
1553
+ if (targetPhase && entry.phase_transition_request !== targetPhase) {
1554
+ return false;
1555
+ }
1556
+ if (sourcePhase && entry.phase && entry.phase !== sourcePhase) {
1557
+ return false;
1558
+ }
1559
+ return true;
1560
+ }) || null;
1561
+ }
1562
+
1563
+ function resolvePhaseTransitionSource(historyEntries, gateFailure, fallbackTurnId, queuedPhaseTransition = null) {
1564
+ const requestedTurnId = gateFailure?.requested_by_turn
1565
+ || queuedPhaseTransition?.requested_by_turn
1566
+ || fallbackTurnId
1567
+ || null;
1568
+ const requestedSource = findHistoryTurnRequest(historyEntries, requestedTurnId, 'phase_transition');
1569
+ if (requestedSource?.phase_transition_request) {
1570
+ return requestedSource;
1571
+ }
1572
+
1573
+ // Turn 94: a bare null-failure path only gets the exact last_completed_turn
1574
+ // lookup. Without a surviving gate_failure or queued_phase_transition
1575
+ // descriptor, mining "the latest request anywhere in history" can replay an
1576
+ // unrelated older phase request on resume.
1577
+ if (!gateFailure && !queuedPhaseTransition) {
1578
+ return requestedSource;
1579
+ }
1580
+
1581
+ const fallbackSource = findMatchingPhaseTransitionDeclarer(
1582
+ historyEntries,
1583
+ gateFailure || (
1584
+ queuedPhaseTransition
1585
+ ? {
1586
+ from_phase: queuedPhaseTransition.from || null,
1587
+ to_phase: queuedPhaseTransition.to || null,
1588
+ }
1589
+ : null
1590
+ ),
1591
+ );
1592
+ if (fallbackSource?.phase_transition_request) {
1593
+ return { ...fallbackSource, phase_transition_request: fallbackSource.phase_transition_request };
1594
+ }
1595
+
1596
+ return requestedSource;
1597
+ }
1598
+
1534
1599
  function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date().toISOString() }) {
1535
1600
  return {
1536
1601
  category,
@@ -2535,7 +2600,19 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
2535
2600
  }
2536
2601
 
2537
2602
  const gateFailure = currentState.last_gate_failure;
2538
- if (gateFailure?.gate_type !== 'phase_transition') {
2603
+ // BUG-52 Turn 93 (DEC-BUG52-NEEDS-HUMAN-PHASE-ADVANCE-001): accept two entry
2604
+ // shapes. (A) gate_failed left `last_gate_failure.gate_type === 'phase_transition'`
2605
+ // (existing Turn 57-60 coverage). (B) The accepted turn emitted
2606
+ // `status: 'needs_human'`, which short-circuits gate evaluation inside
2607
+ // `applyAcceptedTurn` (see needs_human guard at line 4657) — so
2608
+ // `last_gate_failure` stays null and `queued_phase_transition` stays null, but
2609
+ // the turn's `phase_transition_request` is preserved in history. After unblock
2610
+ // clears the human block, we must still attempt to advance using the
2611
+ // history-declared request; otherwise the dispatcher re-dispatches the current
2612
+ // phase's entry role and the tester reproduces the planning_signoff false-loop.
2613
+ // A non-`phase_transition` gate failure (e.g. run_completion) is still a hard
2614
+ // skip — this expansion only opens the null-failure path.
2615
+ if (gateFailure && gateFailure.gate_type !== 'phase_transition') {
2539
2616
  return {
2540
2617
  ok: true,
2541
2618
  state: attachLegacyCurrentTurnAlias(currentState),
@@ -2544,10 +2621,11 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
2544
2621
  }
2545
2622
 
2546
2623
  const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2547
- const phaseSource = findHistoryTurnRequest(
2624
+ const phaseSource = resolvePhaseTransitionSource(
2548
2625
  historyEntries,
2549
- gateFailure.requested_by_turn || currentState.last_completed_turn_id || null,
2550
- 'phase_transition',
2626
+ gateFailure,
2627
+ currentState.last_completed_turn_id || null,
2628
+ currentState.queued_phase_transition || null,
2551
2629
  );
2552
2630
  if (!phaseSource?.phase_transition_request) {
2553
2631
  return {
@@ -442,6 +442,9 @@ export function validateV4Config(data, projectRoot) {
442
442
  if (!VALID_RUNTIME_TYPES.includes(rt.type)) {
443
443
  errors.push(`Runtime "${id}": type must be one of: ${VALID_RUNTIME_TYPES.join(', ')}`);
444
444
  }
445
+ if (rt.type === 'local_cli') {
446
+ validateRuntimePositiveInteger(`Runtime "${id}": startup_watchdog_ms`, rt.startup_watchdog_ms, errors);
447
+ }
445
448
  // Validate prompt_transport for local_cli runtimes
446
449
  if (rt.type === 'local_cli' && rt.prompt_transport) {
447
450
  if (!VALID_PROMPT_TRANSPORTS.includes(rt.prompt_transport)) {
@@ -652,6 +655,15 @@ function validateRunLoopPositiveInteger(path, value, errors) {
652
655
  }
653
656
  }
654
657
 
658
+ function validateRuntimePositiveInteger(path, value, errors) {
659
+ if (value === undefined || value === null) {
660
+ return;
661
+ }
662
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
663
+ errors.push(`${path} must be a positive integer (milliseconds)`);
664
+ }
665
+ }
666
+
655
667
  export function validateBudgetConfig(budget) {
656
668
  const errors = [];
657
669
 
@@ -253,6 +253,11 @@
253
253
  "cwd": {
254
254
  "$ref": "#/$defs/non_empty_string"
255
255
  },
256
+ "startup_watchdog_ms": {
257
+ "type": "integer",
258
+ "minimum": 1,
259
+ "description": "Optional local_cli-specific override for the startup watchdog. When set, this runtime uses the declared threshold before falling back to run_loop.startup_watchdog_ms."
260
+ },
256
261
  "prompt_transport": {
257
262
  "enum": ["argv", "stdin", "dispatch_bundle_only"]
258
263
  },
@@ -24,7 +24,8 @@
24
24
  * requiring a background daemon.
25
25
  *
26
26
  * Default thresholds:
27
- * - Startup watchdog: 30 seconds (configurable via run_loop.startup_watchdog_ms)
27
+ * - Startup watchdog: 30 seconds (configurable via run_loop.startup_watchdog_ms
28
+ * or runtimes.<id>.startup_watchdog_ms for local_cli runtimes)
28
29
  * - local_cli stale turns: 10 minutes
29
30
  * - api_proxy stale turns: 5 minutes
30
31
  * - Configurable via run_loop.stale_turn_threshold_ms in agentxchain.json
@@ -36,6 +37,7 @@ import { safeWriteJson } from './safe-write.js';
36
37
  import { emitRunEvent, readRunEvents } from './run-events.js';
37
38
  import { getTurnStagingResultPath } from './turn-paths.js';
38
39
  import { getDispatchProgressRelativePath } from './dispatch-progress.js';
40
+ import { isPersistedTurnStartupProofStream } from './dispatch-streams.js';
39
41
  import { hasMeaningfulStagedResult } from './staged-result-proof.js';
40
42
 
41
43
  const DEFAULT_LOCAL_CLI_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
@@ -122,7 +124,6 @@ export function detectGhostTurns(root, state, config) {
122
124
  const activeTurns = state?.active_turns || {};
123
125
  const ghosts = [];
124
126
  const now = Date.now();
125
- const startupThreshold = resolveStartupThreshold(config);
126
127
 
127
128
  for (const [turnId, turn] of Object.entries(activeTurns)) {
128
129
  if (!['dispatched', 'starting', 'running', 'retrying'].includes(turn.status)) continue;
@@ -130,6 +131,13 @@ export function detectGhostTurns(root, state, config) {
130
131
  const lifecycleStart = parseGhostLifecycleStart(turn);
131
132
  if (!Number.isFinite(lifecycleStart)) continue;
132
133
 
134
+ // BUG-54 follow-up: per-turn threshold honors per-runtime startup override.
135
+ // Without this, an operator who sets `runtimes.<id>.startup_watchdog_ms`
136
+ // higher than the global to accommodate a slow QA/Claude runtime would still
137
+ // have ghost detection fire at the global threshold, defeating the override.
138
+ const runtime = config?.runtimes?.[turn.runtime_id];
139
+ const startupThreshold = resolveStartupThreshold(config, runtime);
140
+
133
141
  const runningMs = now - lifecycleStart;
134
142
  if (runningMs < startupThreshold) continue;
135
143
 
@@ -274,7 +282,16 @@ function resolveThreshold(turn, config) {
274
282
  return DEFAULT_LOCAL_CLI_THRESHOLD_MS;
275
283
  }
276
284
 
277
- function resolveStartupThreshold(config) {
285
+ function resolveStartupThreshold(config, runtime) {
286
+ // BUG-54 follow-up: per-runtime override beats the global.
287
+ // Mirrors `resolveStartupWatchdogMs()` in local-cli-adapter.js so the
288
+ // ghost-detection scanner uses the same threshold the in-flight adapter
289
+ // watchdog uses; otherwise the scanner pre-empts the override.
290
+ if (runtime && runtime.type === 'local_cli'
291
+ && Number.isInteger(runtime.startup_watchdog_ms)
292
+ && runtime.startup_watchdog_ms > 0) {
293
+ return runtime.startup_watchdog_ms;
294
+ }
278
295
  const configThreshold = config?.run_loop?.startup_watchdog_ms;
279
296
  if (typeof configThreshold === 'number' && configThreshold > 0) {
280
297
  return configThreshold;
@@ -291,6 +308,7 @@ export function failTurnStartup(root, state, config, turnId, details = {}) {
291
308
  if (!turn) {
292
309
  return { ok: false, error: `Turn ${turnId} not found in active turns` };
293
310
  }
311
+ const runtime = config?.runtimes?.[turn.runtime_id];
294
312
 
295
313
  const nowIso = new Date().toISOString();
296
314
  const activeTurns = { ...(state.active_turns || {}) };
@@ -300,7 +318,7 @@ export function failTurnStartup(root, state, config, turnId, details = {}) {
300
318
  role: turn.assigned_role || 'unknown',
301
319
  runtime_id: turn.runtime_id || 'unknown',
302
320
  running_ms: details.running_ms ?? computeLifecycleAgeMs(turn),
303
- threshold_ms: details.threshold_ms ?? resolveStartupThreshold(config),
321
+ threshold_ms: details.threshold_ms ?? resolveStartupThreshold(config, runtime),
304
322
  failure_type: classifyStartupFailureType(turn, null, details.failure_type || 'no_subprocess_output'),
305
323
  recommendation: details.recommendation
306
324
  || `Turn ${turnId} failed to start cleanly. Run \`agentxchain reissue-turn --turn ${turnId} --reason ghost\` to recover.`,
@@ -476,7 +494,14 @@ function mapStartupFailureEventType(failureType) {
476
494
  }
477
495
 
478
496
  function hasStartupProof(turn, progress) {
479
- if (turn.first_output_at) {
497
+ // DEC-BUG54-STDERR-IS-NOT-STARTUP-PROOF-002 (Turn 88) extended to the
498
+ // fast-startup watchdog in Turn 89: stderr activity is not startup proof.
499
+ // A subprocess that spawns and emits stderr-only text must still be caught
500
+ // by the fast watchdog as stdout_attach_failed. Only stdout-derived signals
501
+ // (stream-tagged `turn.first_output_at`, `progress.first_output_at`, or
502
+ // `progress.output_lines`) satisfy startup proof. `progress.stderr_lines`
503
+ // deliberately does NOT.
504
+ if (turn.first_output_at && isPersistedTurnStartupProofStream(turn.first_output_stream)) {
480
505
  return true;
481
506
  }
482
507
  if (!progress || typeof progress !== 'object') {
@@ -485,7 +510,7 @@ function hasStartupProof(turn, progress) {
485
510
  if (progress.first_output_at) {
486
511
  return true;
487
512
  }
488
- return Number(progress.output_lines || 0) > 0 || Number(progress.stderr_lines || 0) > 0;
513
+ return Number(progress.output_lines || 0) > 0;
489
514
  }
490
515
 
491
516
  function hasTurnScopedStagedResult(root, turnId) {
@@ -57,6 +57,14 @@ function normalizeFilesChanged(filesChanged) {
57
57
  return normalizeCheckpointableFiles(filesChanged);
58
58
  }
59
59
 
60
+ function normalizeGitBaselineRef(ref) {
61
+ if (typeof ref !== 'string' || !ref.startsWith('git:')) {
62
+ return null;
63
+ }
64
+ const gitRef = ref.slice(4).trim();
65
+ return gitRef || null;
66
+ }
67
+
60
68
  function supportsLegacyFilesChangedRecovery(entry) {
61
69
  const artifactType = entry?.artifact?.type;
62
70
  return artifactType === 'workspace' || artifactType === 'patch';
@@ -148,14 +156,21 @@ function diffMissingDeclaredPaths(declaredFiles, stagedFiles) {
148
156
  * Partition paths that were missing from the staged-diff into
149
157
  * (a) paths genuinely absent from git (untracked or dirty without staging)
150
158
  * (b) paths already committed upstream (tracked in HEAD, no pending diff)
159
+ * (c) paths tracked and clean at HEAD but unchanged since the accepted
160
+ * baseline — the BUG-55A wrong-lineage case (actor committed the file
161
+ * off the accepted lineage, e.g. on a throwaway side-branch or the
162
+ * lineage was reset to baseline).
151
163
  *
152
- * BUG-55A completeness must only fail on (a). An actor that committed a
153
- * declared file before `checkpoint-turn` ran (see BUG-23 scenario) is
154
- * already-checkpointed-upstream; treating that as "missing from checkpoint"
155
- * is a false positive from the completeness gate.
164
+ * BUG-55A completeness must fail on (a) and (c). (c) has a different
165
+ * operator-visible recovery path than (a): the file DID exist in some
166
+ * commit but is not reachable from the accepted baseline + HEAD comparison
167
+ * the governed run relies on. Surface it separately so the CLI can tell
168
+ * the operator which failure they hit.
156
169
  */
157
- function partitionDeclaredPathsByUpstreamPresence(root, missingPaths) {
170
+ function partitionDeclaredPathsByUpstreamPresence(root, missingPaths, options = {}) {
171
+ const baselineRef = normalizeGitBaselineRef(options.baselineRef);
158
172
  const genuinelyMissing = [];
173
+ const divergentFromAcceptedLineage = [];
159
174
  const alreadyCommittedUpstream = [];
160
175
  for (const filePath of missingPaths) {
161
176
  let tracked = false;
@@ -179,11 +194,36 @@ function partitionDeclaredPathsByUpstreamPresence(root, missingPaths) {
179
194
  }
180
195
  if (hasDivergence) {
181
196
  genuinelyMissing.push(filePath);
182
- } else {
183
- alreadyCommittedUpstream.push(filePath);
197
+ continue;
184
198
  }
199
+
200
+ // BUG-55A wrong-branch guard: a path only counts as already checkpointed
201
+ // if the current branch differs from the accepted baseline on that path.
202
+ if (baselineRef) {
203
+ let changedSinceAcceptedBaseline = false;
204
+ try {
205
+ const baselineDiff = git(root, ['diff', baselineRef, 'HEAD', '--', filePath]);
206
+ changedSinceAcceptedBaseline = Boolean(baselineDiff);
207
+ } catch {
208
+ changedSinceAcceptedBaseline = true;
209
+ }
210
+ if (!changedSinceAcceptedBaseline) {
211
+ divergentFromAcceptedLineage.push(filePath);
212
+ continue;
213
+ }
214
+ }
215
+
216
+ alreadyCommittedUpstream.push(filePath);
185
217
  }
186
- return { genuinelyMissing, alreadyCommittedUpstream };
218
+ // Preserve the pre-existing return shape: `genuinelyMissing` is the union
219
+ // that the completeness gate must fail on. `divergent_from_accepted_lineage`
220
+ // is an additional, operator-facing subcategory that callers can surface
221
+ // without changing the pass/fail contract.
222
+ return {
223
+ genuinelyMissing: [...genuinelyMissing, ...divergentFromAcceptedLineage],
224
+ divergentFromAcceptedLineage,
225
+ alreadyCommittedUpstream,
226
+ };
187
227
  }
188
228
 
189
229
  export function detectPendingCheckpoint(root, dirtyFiles = []) {
@@ -281,14 +321,21 @@ export function checkpointAcceptedTurn(root, opts = {}) {
281
321
  }
282
322
 
283
323
  const rawMissingFromStage = diffMissingDeclaredPaths(filesChanged, staged);
284
- const { genuinelyMissing, alreadyCommittedUpstream } =
285
- partitionDeclaredPathsByUpstreamPresence(root, rawMissingFromStage);
324
+ const { genuinelyMissing, divergentFromAcceptedLineage, alreadyCommittedUpstream } =
325
+ partitionDeclaredPathsByUpstreamPresence(root, rawMissingFromStage, {
326
+ baselineRef: entry?.observed_artifact?.baseline_ref ?? null,
327
+ });
286
328
  if (genuinelyMissing.length > 0) {
329
+ const baseMessage = `Checkpoint completeness failure: accepted turn ${entry.turn_id} declared ${filesChanged.length} checkpointable file(s), but Git staged only ${staged.length} and ${genuinelyMissing.length} declared path(s) are absent from git. Missing from checkpoint: ${genuinelyMissing.join(', ')}.`;
330
+ const lineageHint = divergentFromAcceptedLineage.length > 0
331
+ ? ` Wrong-lineage paths (tracked at HEAD but unchanged since accepted baseline — actor likely committed off the accepted lineage): ${divergentFromAcceptedLineage.join(', ')}.`
332
+ : '';
287
333
  return {
288
334
  ok: false,
289
335
  turn: entry,
290
- error: `Checkpoint completeness failure: accepted turn ${entry.turn_id} declared ${filesChanged.length} checkpointable file(s), but Git staged only ${staged.length} and ${genuinelyMissing.length} declared path(s) are absent from git. Missing from checkpoint: ${genuinelyMissing.join(', ')}.`,
336
+ error: `${baseMessage}${lineageHint}`,
291
337
  missing_declared_paths: genuinelyMissing,
338
+ divergent_from_accepted_lineage: divergentFromAcceptedLineage,
292
339
  already_committed_upstream: alreadyCommittedUpstream,
293
340
  staged_paths: staged,
294
341
  };