agentxchain 2.143.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 +83 -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/release-alignment.js +6 -0
- package/src/lib/repo-observer.js +43 -4
- 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 +89 -6
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}"
|
|
@@ -79,6 +81,7 @@ ALLOWED_RELEASE_PATHS=(
|
|
|
79
81
|
".planning/LAUNCH_EVIDENCE_REPORT.md"
|
|
80
82
|
".planning/SHOW_HN_DRAFT.md"
|
|
81
83
|
".planning/MARKETING/TWITTER_THREAD.md"
|
|
84
|
+
".planning/MARKETING/LINKEDIN_POST.md"
|
|
82
85
|
".planning/MARKETING/REDDIT_POSTS.md"
|
|
83
86
|
".planning/MARKETING/HN_SUBMISSION.md"
|
|
84
87
|
"website-v2/static/llms.txt"
|
|
@@ -88,6 +91,10 @@ ALLOWED_RELEASE_PATHS=(
|
|
|
88
91
|
"cli/homebrew/agentxchain.rb"
|
|
89
92
|
"cli/homebrew/README.md"
|
|
90
93
|
)
|
|
94
|
+
ALLOWED_REENTRY_VERSION_PATHS=(
|
|
95
|
+
"cli/package.json"
|
|
96
|
+
"cli/package-lock.json"
|
|
97
|
+
)
|
|
91
98
|
|
|
92
99
|
is_allowed_release_path() {
|
|
93
100
|
local candidate="$1"
|
|
@@ -111,15 +118,32 @@ stage_if_present() {
|
|
|
111
118
|
fi
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
# 1.
|
|
115
|
-
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..."
|
|
116
132
|
DISALLOWED_DIRTY=()
|
|
117
133
|
while IFS= read -r status_line; do
|
|
118
134
|
[[ -z "$status_line" ]] && continue
|
|
119
135
|
path="${status_line#?? }"
|
|
120
|
-
if
|
|
121
|
-
|
|
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
|
|
122
145
|
fi
|
|
146
|
+
DISALLOWED_DIRTY+=("$path")
|
|
123
147
|
done < <(git -C "$REPO_ROOT" status --porcelain)
|
|
124
148
|
|
|
125
149
|
if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
|
|
@@ -129,17 +153,8 @@ if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
|
|
|
129
153
|
fi
|
|
130
154
|
echo " OK: tree contains only allowed release-prep changes"
|
|
131
155
|
|
|
132
|
-
# 2. Assert not already at target version
|
|
133
|
-
echo "[2/8] Checking current version..."
|
|
134
|
-
CURRENT_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)")
|
|
135
|
-
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
|
|
136
|
-
echo "FAIL: package.json is already at ${TARGET_VERSION}. Cannot double-bump." >&2
|
|
137
|
-
exit 1
|
|
138
|
-
fi
|
|
139
|
-
echo " OK: current version is ${CURRENT_VERSION}, bumping to ${TARGET_VERSION}"
|
|
140
|
-
|
|
141
156
|
# 3. Assert tag does not already exist
|
|
142
|
-
echo "[3/
|
|
157
|
+
echo "[3/10] Checking for existing tag..."
|
|
143
158
|
if git rev-parse "v${TARGET_VERSION}" >/dev/null 2>&1; then
|
|
144
159
|
echo "FAIL: tag v${TARGET_VERSION} already exists. Delete it first or choose a different version." >&2
|
|
145
160
|
exit 1
|
|
@@ -235,7 +250,11 @@ fi
|
|
|
235
250
|
|
|
236
251
|
# 7. Update version files (no git operations)
|
|
237
252
|
echo "[7/10] Updating version files..."
|
|
238
|
-
|
|
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
|
|
239
258
|
echo " OK: package.json updated to ${TARGET_VERSION}"
|
|
240
259
|
|
|
241
260
|
# 8. Stage version files
|
|
@@ -250,23 +269,58 @@ done
|
|
|
250
269
|
git -C "$REPO_ROOT" add -- website-v2/docs/releases
|
|
251
270
|
echo " OK: version files and allowed release surfaces staged"
|
|
252
271
|
|
|
253
|
-
# 9. Create release commit
|
|
254
|
-
|
|
255
|
-
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}
|
|
256
309
|
|
|
257
310
|
Co-Authored-By: ${COAUTHORED_BY}"
|
|
258
|
-
RELEASE_SHA=$(git rev-parse HEAD)
|
|
259
|
-
COMMIT_MSG=$(git log -1 --format=%s)
|
|
260
|
-
if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
fi
|
|
264
|
-
COMMIT_BODY=$(git log -1 --format=%B)
|
|
265
|
-
if [[ "$COMMIT_BODY" != *"Co-Authored-By: ${COAUTHORED_BY}"* ]]; then
|
|
266
|
-
|
|
267
|
-
|
|
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}'"
|
|
268
323
|
fi
|
|
269
|
-
echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
|
|
270
324
|
|
|
271
325
|
# 9.5. Inline preflight gate — tests, pack, and docs build must pass before tag
|
|
272
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}]` : ''}`;
|
|
@@ -279,6 +279,12 @@ export const RELEASE_ALIGNMENT_SURFACES = [
|
|
|
279
279
|
scopes: [RELEASE_ALIGNMENT_SCOPES.PREBUMP, RELEASE_ALIGNMENT_SCOPES.CURRENT],
|
|
280
280
|
check: validateTextIncludesVersionAndEvidence('.planning/MARKETING/TWITTER_THREAD.md', 'twitter thread draft').check,
|
|
281
281
|
},
|
|
282
|
+
{
|
|
283
|
+
id: 'linkedin_post',
|
|
284
|
+
label: 'linkedin release post draft',
|
|
285
|
+
scopes: [RELEASE_ALIGNMENT_SCOPES.PREBUMP, RELEASE_ALIGNMENT_SCOPES.CURRENT],
|
|
286
|
+
check: validateTextIncludesVersionAndEvidence('.planning/MARKETING/LINKEDIN_POST.md', 'linkedin release post draft').check,
|
|
287
|
+
},
|
|
282
288
|
{
|
|
283
289
|
id: 'reddit_posts',
|
|
284
290
|
label: 'reddit posts draft',
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -31,6 +31,7 @@ export const OPERATIONAL_PATH_PREFIXES = Object.freeze([
|
|
|
31
31
|
'.agentxchain/transactions/',
|
|
32
32
|
'.agentxchain/missions/',
|
|
33
33
|
'.agentxchain/multirepo/',
|
|
34
|
+
'.agentxchain/plugins/',
|
|
34
35
|
'.agentxchain/prompts/',
|
|
35
36
|
]);
|
|
36
37
|
|
|
@@ -84,18 +85,56 @@ export const RUN_CONTINUITY_DIRECTORY_ROOTS = Object.freeze([
|
|
|
84
85
|
...BASELINE_EXEMPT_PATH_PREFIXES.map((prefix) => prefix.replace(/\/$/, '')),
|
|
85
86
|
]);
|
|
86
87
|
|
|
88
|
+
function pathMatchesAnyPrefix(filePath, prefixes) {
|
|
89
|
+
return prefixes.some((prefix) => filePath.startsWith(prefix));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pathMatchesAnyRoot(filePath, roots) {
|
|
93
|
+
return roots.some((root) => filePath === root || filePath.startsWith(`${root}/`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Return the repo-owned vs framework-owned classification flags for a path.
|
|
98
|
+
*
|
|
99
|
+
* The categories intentionally overlap:
|
|
100
|
+
* - continuity state is always baseline-exempt
|
|
101
|
+
* - operational paths are always baseline-exempt
|
|
102
|
+
* - some baseline-exempt evidence paths also participate in continuity export
|
|
103
|
+
*
|
|
104
|
+
* This overlap is the contract. The framework does NOT use mutually exclusive
|
|
105
|
+
* buckets for observation, clean-baseline checks, and continuity export.
|
|
106
|
+
*/
|
|
107
|
+
export function classifyRepoPath(filePath) {
|
|
108
|
+
const operational = pathMatchesAnyPrefix(filePath, OPERATIONAL_PATH_PREFIXES)
|
|
109
|
+
|| ORCHESTRATOR_STATE_FILES.includes(filePath);
|
|
110
|
+
const continuityState = RUN_CONTINUITY_STATE_FILES.includes(filePath)
|
|
111
|
+
|| pathMatchesAnyRoot(filePath, RUN_CONTINUITY_DIRECTORY_ROOTS);
|
|
112
|
+
const baselineExempt = operational
|
|
113
|
+
|| continuityState
|
|
114
|
+
|| pathMatchesAnyPrefix(filePath, BASELINE_EXEMPT_PATH_PREFIXES);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
operational,
|
|
118
|
+
baselineExempt,
|
|
119
|
+
continuityState,
|
|
120
|
+
projectOwned: !baselineExempt,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
87
124
|
/**
|
|
88
125
|
* Check whether a file path belongs to orchestrator-owned operational state.
|
|
89
126
|
* These paths are excluded from actor-attributed observation.
|
|
90
127
|
*/
|
|
91
128
|
export function isOperationalPath(filePath) {
|
|
92
|
-
return
|
|
93
|
-
|| ORCHESTRATOR_STATE_FILES.includes(filePath);
|
|
129
|
+
return classifyRepoPath(filePath).operational;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
export function isBaselineExemptPath(filePath) {
|
|
97
|
-
return
|
|
98
|
-
|
|
133
|
+
return classifyRepoPath(filePath).baselineExempt;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isRunContinuityPath(filePath) {
|
|
137
|
+
return classifyRepoPath(filePath).continuityState;
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
export function normalizeCheckpointableFiles(filesChanged) {
|
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
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { resolveAcceptedTurnHistoryReference } from './accepted-turn-history.js';
|
|
4
|
+
import { queryAcceptedTurnHistory, resolveAcceptedTurnHistoryReference } from './accepted-turn-history.js';
|
|
5
5
|
import { emitRunEvent } from './run-events.js';
|
|
6
6
|
import { safeWriteJson } from './safe-write.js';
|
|
7
|
-
import { normalizeCheckpointableFiles } from './repo-observer.js';
|
|
7
|
+
import { checkCleanBaseline, normalizeCheckpointableFiles } from './repo-observer.js';
|
|
8
8
|
|
|
9
9
|
const STATE_PATH = '.agentxchain/state.json';
|
|
10
10
|
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
@@ -57,6 +57,68 @@ function normalizeFilesChanged(filesChanged) {
|
|
|
57
57
|
return normalizeCheckpointableFiles(filesChanged);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function supportsLegacyFilesChangedRecovery(entry) {
|
|
61
|
+
const artifactType = entry?.artifact?.type;
|
|
62
|
+
return artifactType === 'workspace' || artifactType === 'patch';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getActorDirtyFiles(root, dirtyFiles = null) {
|
|
66
|
+
if (Array.isArray(dirtyFiles)) {
|
|
67
|
+
return normalizeFilesChanged(dirtyFiles);
|
|
68
|
+
}
|
|
69
|
+
const cleanCheck = checkCleanBaseline(root, 'authoritative');
|
|
70
|
+
return cleanCheck.clean ? [] : normalizeFilesChanged(cleanCheck.dirty_files);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function recoverLegacyCheckpointFiles(root, entry, opts = {}) {
|
|
74
|
+
if (!supportsLegacyFilesChangedRecovery(entry) || entry?.checkpoint_sha) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const state = readState(root);
|
|
79
|
+
if (state && Object.keys(state.active_turns || {}).length > 0) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const acceptedHistory = queryAcceptedTurnHistory(root);
|
|
84
|
+
if (acceptedHistory[0]?.turn_id !== entry?.turn_id) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return getActorDirtyFiles(root, opts.dirtyFiles);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function persistRecoveredFilesChanged(root, turnId, recoveredFiles) {
|
|
92
|
+
const normalizedRecoveredFiles = normalizeFilesChanged(recoveredFiles);
|
|
93
|
+
if (normalizedRecoveredFiles.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const recoveredAt = new Date().toISOString();
|
|
96
|
+
const nextEntries = readHistoryEntries(root).map((historyEntry) => {
|
|
97
|
+
if (historyEntry.turn_id !== turnId) {
|
|
98
|
+
return historyEntry;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const observedArtifact = historyEntry?.observed_artifact && typeof historyEntry.observed_artifact === 'object'
|
|
102
|
+
? historyEntry.observed_artifact
|
|
103
|
+
: null;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
...historyEntry,
|
|
107
|
+
files_changed: normalizedRecoveredFiles,
|
|
108
|
+
files_changed_recovered_at: recoveredAt,
|
|
109
|
+
files_changed_recovery_source: 'legacy_dirty_worktree',
|
|
110
|
+
observed_artifact: observedArtifact
|
|
111
|
+
? {
|
|
112
|
+
...observedArtifact,
|
|
113
|
+
files_changed: normalizedRecoveredFiles,
|
|
114
|
+
}
|
|
115
|
+
: observedArtifact,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
writeHistoryEntries(root, nextEntries);
|
|
120
|
+
}
|
|
121
|
+
|
|
60
122
|
function extractGitError(err) {
|
|
61
123
|
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
|
|
62
124
|
const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '';
|
|
@@ -88,15 +150,22 @@ export function detectPendingCheckpoint(root, dirtyFiles = []) {
|
|
|
88
150
|
if (entry.checkpoint_sha) return { required: false };
|
|
89
151
|
|
|
90
152
|
const turnFiles = normalizeFilesChanged(entry.files_changed);
|
|
91
|
-
|
|
153
|
+
const recoveredFiles = turnFiles.length === 0
|
|
154
|
+
? recoverLegacyCheckpointFiles(root, entry, { dirtyFiles: actorDirtyFiles })
|
|
155
|
+
: [];
|
|
156
|
+
const effectiveTurnFiles = turnFiles.length > 0 ? turnFiles : recoveredFiles;
|
|
157
|
+
if (effectiveTurnFiles.length === 0) return { required: false };
|
|
92
158
|
|
|
93
|
-
const dirtyOutsideTurn = actorDirtyFiles.filter((file) => !
|
|
159
|
+
const dirtyOutsideTurn = actorDirtyFiles.filter((file) => !effectiveTurnFiles.includes(file));
|
|
94
160
|
if (dirtyOutsideTurn.length > 0) return { required: false };
|
|
95
161
|
|
|
96
162
|
return {
|
|
97
163
|
required: true,
|
|
98
164
|
turn_id: entry.turn_id,
|
|
99
|
-
|
|
165
|
+
recovered_files_changed: recoveredFiles.length > 0 ? recoveredFiles : undefined,
|
|
166
|
+
message: recoveredFiles.length > 0
|
|
167
|
+
? `Accepted turn ${entry.turn_id} has legacy-empty files_changed history but still owns ${recoveredFiles.length} dirty actor file(s). Run agentxchain checkpoint-turn --turn ${entry.turn_id} to recover and checkpoint them.`
|
|
168
|
+
: `Accepted turn ${entry.turn_id} is not checkpointed yet. Run agentxchain checkpoint-turn --turn ${entry.turn_id} before assigning the next code-writing turn.`,
|
|
100
169
|
};
|
|
101
170
|
}
|
|
102
171
|
|
|
@@ -120,7 +189,17 @@ export function checkpointAcceptedTurn(root, opts = {}) {
|
|
|
120
189
|
};
|
|
121
190
|
}
|
|
122
191
|
|
|
123
|
-
const
|
|
192
|
+
const declaredFilesChanged = normalizeFilesChanged(entry.files_changed);
|
|
193
|
+
const recoveredFilesChanged = declaredFilesChanged.length === 0
|
|
194
|
+
? recoverLegacyCheckpointFiles(root, entry)
|
|
195
|
+
: [];
|
|
196
|
+
const filesChanged = declaredFilesChanged.length > 0
|
|
197
|
+
? declaredFilesChanged
|
|
198
|
+
: recoveredFilesChanged;
|
|
199
|
+
if (recoveredFilesChanged.length > 0) {
|
|
200
|
+
persistRecoveredFilesChanged(root, entry.turn_id, recoveredFilesChanged);
|
|
201
|
+
}
|
|
202
|
+
|
|
124
203
|
if (filesChanged.length === 0) {
|
|
125
204
|
return {
|
|
126
205
|
ok: true,
|
|
@@ -188,6 +267,10 @@ export function checkpointAcceptedTurn(root, opts = {}) {
|
|
|
188
267
|
if (state) {
|
|
189
268
|
writeState(root, {
|
|
190
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}`,
|
|
191
274
|
last_completed_turn: {
|
|
192
275
|
turn_id: entry.turn_id,
|
|
193
276
|
role: entry.role || null,
|