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.
- package/dashboard/components/timeline.js +15 -2
- package/package.json +1 -1
- package/scripts/reproduce-bug-54.mjs +623 -0
- package/src/commands/connector.js +23 -4
- package/src/commands/doctor.js +11 -0
- package/src/commands/run.js +18 -3
- package/src/commands/status.js +30 -3
- package/src/commands/step.js +8 -2
- package/src/lib/adapters/local-cli-adapter.js +49 -2
- package/src/lib/claude-local-auth.js +61 -0
- package/src/lib/connector-probe.js +48 -21
- package/src/lib/connector-validate.js +34 -0
- package/src/lib/dispatch-progress.js +32 -6
- package/src/lib/dispatch-streams.js +21 -0
- package/src/lib/governed-state.js +84 -6
- package/src/lib/normalized-config.js +12 -0
- package/src/lib/schemas/agentxchain-config.schema.json +5 -0
- package/src/lib/stale-turn-watchdog.js +31 -6
- package/src/lib/turn-checkpoint.js +58 -11
|
@@ -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
|
-
|
|
999
|
-
|
|
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
|
-
|
|
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 =
|
|
2624
|
+
const phaseSource = resolvePhaseTransitionSource(
|
|
2548
2625
|
historyEntries,
|
|
2549
|
-
gateFailure
|
|
2550
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
};
|