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 +1 -1
- package/scripts/release-bump.sh +82 -29
- package/src/commands/resume.js +21 -0
- package/src/commands/status.js +24 -3
- package/src/commands/step.js +21 -0
- package/src/lib/governed-state.js +29 -0
- package/src/lib/intake.js +47 -0
- package/src/lib/intent-startup-migration.js +23 -1
- package/src/lib/recent-event-summary.js +1 -0
- package/src/lib/run-events.js +1 -0
- package/src/lib/run-history.js +23 -2
- package/src/lib/run-loop.js +3 -2
- package/src/lib/stale-turn-watchdog.js +234 -0
- package/src/lib/turn-checkpoint.js +4 -0
package/package.json
CHANGED
package/scripts/release-bump.sh
CHANGED
|
@@ -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.
|
|
116
|
-
echo "[1/
|
|
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
|
|
122
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
git
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
fi
|
|
265
|
-
COMMIT_BODY=$(git log -1 --format=%B)
|
|
266
|
-
if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
|
|
267
|
-
|
|
268
|
-
|
|
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
|
package/src/commands/resume.js
CHANGED
|
@@ -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 }) {
|
package/src/commands/status.js
CHANGED
|
@@ -21,10 +21,11 @@ import { deriveConflictedTurnResolutionActions } from '../lib/conflict-actions.j
|
|
|
21
21
|
import { summarizeLatestGateActionAttempt } from '../lib/gate-actions.js';
|
|
22
22
|
import { findCurrentHumanEscalation } from '../lib/human-escalations.js';
|
|
23
23
|
import { getDashboardPid, getDashboardSession } from './dashboard.js';
|
|
24
|
-
import { readPreemptionMarker, findPendingApprovedIntents } from '../lib/intake.js';
|
|
24
|
+
import { readPreemptionMarker, validatePreemptionMarker, findPendingApprovedIntents } from '../lib/intake.js';
|
|
25
25
|
import { readContinuousSession } from '../lib/continuous-run.js';
|
|
26
26
|
import { readAllDispatchProgress } from '../lib/dispatch-progress.js';
|
|
27
27
|
import { readCoordinatorWarnings } from '../lib/coordinator-warnings.js';
|
|
28
|
+
import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
28
29
|
|
|
29
30
|
export async function statusCommand(opts) {
|
|
30
31
|
const context = loadStatusContext();
|
|
@@ -134,7 +135,7 @@ function loadStatusContext(dir = process.cwd()) {
|
|
|
134
135
|
|
|
135
136
|
function renderGovernedStatus(context, opts) {
|
|
136
137
|
const { root, config, version } = context;
|
|
137
|
-
|
|
138
|
+
let state = loadProjectState(root, config);
|
|
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
|
-
|
|
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;
|
package/src/commands/step.js
CHANGED
|
@@ -70,6 +70,7 @@ import { resolveGovernedRole } from '../lib/role-resolution.js';
|
|
|
70
70
|
import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
|
|
71
71
|
import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
|
|
72
72
|
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
73
|
+
import { reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
|
|
73
74
|
|
|
74
75
|
export async function stepCommand(opts) {
|
|
75
76
|
const context = loadProjectContext();
|
|
@@ -94,6 +95,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}]` : ''}`;
|
package/src/lib/run-events.js
CHANGED
package/src/lib/run-history.js
CHANGED
|
@@ -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
|
|
45
|
+
const allHistoryEntries = readJsonlSafe(root, HISTORY_PATH);
|
|
46
46
|
const ledgerEntries = readJsonlSafe(root, LEDGER_PATH);
|
|
47
47
|
|
|
48
|
-
//
|
|
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;
|
package/src/lib/run-loop.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|