agentxchain 2.146.0 → 2.148.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/publish-npm.sh +16 -0
- package/scripts/sync-homebrew.sh +14 -1
- package/scripts/verify-post-publish.sh +55 -4
- package/src/commands/reissue-turn.js +16 -0
- package/src/commands/reject-turn.js +14 -1
- package/src/commands/restart.js +15 -0
- package/src/commands/resume.js +61 -66
- package/src/commands/run.js +67 -10
- package/src/commands/schedule.js +34 -7
- package/src/commands/status.js +20 -0
- package/src/commands/step.js +100 -34
- package/src/lib/adapters/api-proxy-adapter.js +8 -0
- package/src/lib/adapters/local-cli-adapter.js +271 -16
- package/src/lib/adapters/manual-adapter.js +9 -10
- package/src/lib/adapters/mcp-adapter.js +3 -5
- package/src/lib/adapters/remote-agent-adapter.js +3 -5
- package/src/lib/continuous-run.js +71 -6
- package/src/lib/dispatch-bundle.js +1 -1
- package/src/lib/dispatch-progress.js +5 -3
- package/src/lib/governed-state.js +258 -17
- package/src/lib/intake.js +10 -1
- package/src/lib/normalized-config.js +51 -1
- package/src/lib/recent-event-summary.js +11 -0
- package/src/lib/run-events.js +4 -0
- package/src/lib/run-loop.js +67 -2
- package/src/lib/runner-interface.js +1 -0
- package/src/lib/schema.js +7 -0
- package/src/lib/schemas/agentxchain-config.schema.json +15 -1
- package/src/lib/schemas/turn-result.schema.json +8 -2
- package/src/lib/staged-result-proof.js +43 -0
- package/src/lib/stale-turn-watchdog.js +218 -90
- package/src/lib/turn-checkpoint.js +65 -1
- package/src/lib/turn-result-shape.js +38 -0
- package/src/lib/turn-result-validator.js +15 -3
package/package.json
CHANGED
package/scripts/publish-npm.sh
CHANGED
|
@@ -84,6 +84,22 @@ NEW_VERSION="$(node -e "console.log(JSON.parse(require('fs').readFileSync('packa
|
|
|
84
84
|
echo "New version: ${NEW_VERSION}"
|
|
85
85
|
echo ""
|
|
86
86
|
|
|
87
|
+
# Publish-gate enforcement: WAYS-OF-WORKING section 9 prohibits bypassing the
|
|
88
|
+
# publish gate with manual npm publish. Even though this script is a documented
|
|
89
|
+
# non-canonical helper (see RELEASE_CUT_SPEC.md section 6), it must not drop
|
|
90
|
+
# below the same release-boundary proof surface (claim-reality-preflight,
|
|
91
|
+
# beta-tester scenarios, release-docs-content, release-preflight) that the
|
|
92
|
+
# canonical publish-npm-on-tag.yml workflow runs via
|
|
93
|
+
# `release-preflight.sh --publish-gate`. Set ALLOW_PUBLISH_GATE_BYPASS=1 only
|
|
94
|
+
# for dry-run/debug paths that have manually established proof (for example,
|
|
95
|
+
# a release-preflight.sh --publish-gate run the operator just watched pass).
|
|
96
|
+
if [[ "${ALLOW_PUBLISH_GATE_BYPASS:-0}" != "1" ]]; then
|
|
97
|
+
echo "Running release-preflight.sh --publish-gate before npm publish..."
|
|
98
|
+
bash scripts/release-preflight.sh --publish-gate --target-version "${NEW_VERSION}"
|
|
99
|
+
else
|
|
100
|
+
echo "WARNING: ALLOW_PUBLISH_GATE_BYPASS=1 set — skipping publish-gate. The operator owns claim-reality/beta-tester proof manually."
|
|
101
|
+
fi
|
|
102
|
+
|
|
87
103
|
echo "Publishing to npm..."
|
|
88
104
|
if [[ -n "${NPM_TOKEN:-}" ]]; then
|
|
89
105
|
npm publish --access public --//registry.npmjs.org/:_authToken="${NPM_TOKEN}"
|
package/scripts/sync-homebrew.sh
CHANGED
|
@@ -244,5 +244,18 @@ fi
|
|
|
244
244
|
|
|
245
245
|
echo ""
|
|
246
246
|
echo "====================================="
|
|
247
|
-
echo "SYNC COMPLETE — Homebrew formula updated to ${PACKAGE_NAME}@${TARGET_VERSION}."
|
|
247
|
+
echo "SYNC STEP COMPLETE — Homebrew formula updated to ${PACKAGE_NAME}@${TARGET_VERSION}."
|
|
248
|
+
echo ""
|
|
249
|
+
echo "This is the Phase 2 -> Phase 3 transition step only. It does NOT prove"
|
|
250
|
+
echo "the public npx install path resolves, and it does NOT prove the canonical"
|
|
251
|
+
echo "tap / GitHub Release / repo-mirror downstream truth is consistent."
|
|
252
|
+
echo ""
|
|
253
|
+
echo "Do NOT declare the release complete from this script's exit code alone."
|
|
254
|
+
echo "Complete the release by running ONE of:"
|
|
255
|
+
echo " - bash cli/scripts/verify-post-publish.sh --target-version ${TARGET_VERSION}"
|
|
256
|
+
echo " (manual/operator path; includes npx smoke + repo-mirror SHA proof + full test suite)"
|
|
257
|
+
echo " - bash cli/scripts/release-downstream-truth.sh --target-version ${TARGET_VERSION}"
|
|
258
|
+
echo " (CI-equivalent path; requires release-postflight.sh to have already run the npx smoke)"
|
|
259
|
+
echo ""
|
|
260
|
+
echo "See DEC-VERIFY-POST-PUBLISH-NPX-001 and DEC-HOMEBREW-SYNC-LOOPHOLE-CLOSE-001."
|
|
248
261
|
exit 0
|
|
@@ -20,6 +20,8 @@ cd "$CLI_DIR"
|
|
|
20
20
|
TARGET_VERSION=""
|
|
21
21
|
|
|
22
22
|
FORMULA_PATH="${CLI_DIR}/homebrew/agentxchain.rb"
|
|
23
|
+
PACKAGE_NAME="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).name)")"
|
|
24
|
+
PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json','utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const names = Object.keys(pkg.bin || {}); if (names.length !== 1) { console.error('package.json bin must declare exactly one entry'); process.exit(1); } console.log(names[0]);")"
|
|
23
25
|
|
|
24
26
|
formula_url() {
|
|
25
27
|
local formula_path="$1"
|
|
@@ -31,6 +33,29 @@ formula_sha() {
|
|
|
31
33
|
grep -E '^\s*sha256\s+"' "$formula_path" | sed 's/.*sha256 *"\([a-f0-9]*\)".*/\1/' || true
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
run_npx_smoke() {
|
|
37
|
+
local smoke_root
|
|
38
|
+
local smoke_npmrc
|
|
39
|
+
|
|
40
|
+
smoke_root="$(mktemp -d "${TMPDIR:-/tmp}/agentxchain-verify-post-publish.XXXXXX")"
|
|
41
|
+
mkdir -p "${smoke_root}/home" "${smoke_root}/cache" "${smoke_root}/npm-cache" "${smoke_root}/workspace"
|
|
42
|
+
smoke_npmrc="${smoke_root}/.npmrc"
|
|
43
|
+
echo "registry=https://registry.npmjs.org/" > "$smoke_npmrc"
|
|
44
|
+
|
|
45
|
+
(
|
|
46
|
+
cd "${smoke_root}/workspace" || exit 1
|
|
47
|
+
env -u NODE_AUTH_TOKEN \
|
|
48
|
+
HOME="${smoke_root}/home" \
|
|
49
|
+
XDG_CACHE_HOME="${smoke_root}/cache" \
|
|
50
|
+
NPM_CONFIG_CACHE="${smoke_root}/npm-cache" \
|
|
51
|
+
NPM_CONFIG_USERCONFIG="${smoke_npmrc}" \
|
|
52
|
+
npx --yes -p "${PACKAGE_NAME}@${TARGET_VERSION}" -c "${PACKAGE_BIN_NAME} --version"
|
|
53
|
+
)
|
|
54
|
+
local status=$?
|
|
55
|
+
rm -rf "$smoke_root"
|
|
56
|
+
return "$status"
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
while [[ $# -gt 0 ]]; do
|
|
35
60
|
case "$1" in
|
|
36
61
|
--target-version)
|
|
@@ -57,7 +82,7 @@ echo "============================================="
|
|
|
57
82
|
echo ""
|
|
58
83
|
|
|
59
84
|
# Step 1: Verify npm serves the version
|
|
60
|
-
echo "[1/
|
|
85
|
+
echo "[1/5] Checking npm registry..."
|
|
61
86
|
NPM_VERSION="$(npm view "agentxchain@${TARGET_VERSION}" version 2>/dev/null || echo "")"
|
|
62
87
|
if [[ "$NPM_VERSION" != "$TARGET_VERSION" ]]; then
|
|
63
88
|
echo " FAIL: npm does not serve agentxchain@${TARGET_VERSION} (got: '${NPM_VERSION}')"
|
|
@@ -67,7 +92,7 @@ fi
|
|
|
67
92
|
echo " OK: npm serves v${TARGET_VERSION}"
|
|
68
93
|
|
|
69
94
|
# Step 2: Sync the repo mirror to the published tarball
|
|
70
|
-
echo "[2/
|
|
95
|
+
echo "[2/5] Syncing repo mirror to published tarball..."
|
|
71
96
|
bash "${SCRIPT_DIR}/sync-homebrew.sh" --target-version "$TARGET_VERSION"
|
|
72
97
|
echo " OK: repo mirror synced"
|
|
73
98
|
|
|
@@ -109,8 +134,34 @@ if [[ "$FORMULA_SHA" != "$TARBALL_SHA" ]]; then
|
|
|
109
134
|
fi
|
|
110
135
|
echo " OK: repo mirror formula SHA256 matches registry tarball"
|
|
111
136
|
|
|
112
|
-
# Step 4:
|
|
113
|
-
echo "[4/5]
|
|
137
|
+
# Step 4: Recheck the public npx path against the published registry version
|
|
138
|
+
echo "[4/5] Verifying public npx install path..."
|
|
139
|
+
NPX_OUTPUT="$(run_npx_smoke 2>&1 || true)"
|
|
140
|
+
NPX_VERSION="$(printf '%s\n' "$NPX_OUTPUT" | awk -v expected="${TARGET_VERSION}" '
|
|
141
|
+
{
|
|
142
|
+
line=$0
|
|
143
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
|
|
144
|
+
if (line == expected) {
|
|
145
|
+
print line
|
|
146
|
+
found=1
|
|
147
|
+
exit
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
END {
|
|
151
|
+
if (!found) {
|
|
152
|
+
exit 1
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
' || true)"
|
|
156
|
+
if [[ "$NPX_VERSION" != "$TARGET_VERSION" ]]; then
|
|
157
|
+
echo " FAIL: public npx path did not report ${TARGET_VERSION}"
|
|
158
|
+
printf '%s\n' "$NPX_OUTPUT"
|
|
159
|
+
exit 1
|
|
160
|
+
fi
|
|
161
|
+
echo " OK: public npx path resolves and reports v${TARGET_VERSION}"
|
|
162
|
+
|
|
163
|
+
# Step 5: Run the full test suite WITHOUT the preflight skip
|
|
164
|
+
echo "[5/5] Running full test suite (no preflight skip)..."
|
|
114
165
|
echo " This verifies the broader Homebrew mirror contract passes with the real SHA."
|
|
115
166
|
npm test
|
|
116
167
|
echo " OK: full test suite green"
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
getActiveTurns,
|
|
19
19
|
getActiveTurn,
|
|
20
20
|
reissueTurn,
|
|
21
|
+
transitionActiveTurnLifecycle,
|
|
21
22
|
} from '../lib/governed-state.js';
|
|
22
23
|
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
24
|
+
import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
23
25
|
|
|
24
26
|
export async function reissueTurnCommand(opts) {
|
|
25
27
|
const context = loadProjectContext();
|
|
@@ -91,6 +93,20 @@ export async function reissueTurnCommand(opts) {
|
|
|
91
93
|
console.log(chalk.red(`Turn reissued but dispatch bundle failed: ${bundleResult.error}`));
|
|
92
94
|
process.exit(1);
|
|
93
95
|
}
|
|
96
|
+
// BUG-51 follow-up: every command that writes a dispatch bundle for an
|
|
97
|
+
// active turn must finalize the manifest so adapter-side
|
|
98
|
+
// `verifyDispatchManifestForAdapter` enforcement matches fresh dispatches.
|
|
99
|
+
// Reissue does not run after_dispatch hooks, so finalization happens
|
|
100
|
+
// immediately after writeDispatchBundle.
|
|
101
|
+
const manifestResult = finalizeDispatchManifest(root, result.newTurn.turn_id, {
|
|
102
|
+
run_id: result.state.run_id,
|
|
103
|
+
role: result.newTurn.assigned_role,
|
|
104
|
+
});
|
|
105
|
+
if (!manifestResult.ok) {
|
|
106
|
+
console.log(chalk.red(`Turn reissued but dispatch manifest failed: ${manifestResult.error}`));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
transitionActiveTurnLifecycle(root, result.newTurn.turn_id, 'dispatched');
|
|
94
110
|
|
|
95
111
|
// Print summary
|
|
96
112
|
console.log('');
|
|
@@ -2,9 +2,10 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
5
|
-
import { getActiveTurns, rejectGovernedTurn } from '../lib/governed-state.js';
|
|
5
|
+
import { getActiveTurns, rejectGovernedTurn, transitionActiveTurnLifecycle } from '../lib/governed-state.js';
|
|
6
6
|
import { validateStagedTurnResult } from '../lib/turn-result-validator.js';
|
|
7
7
|
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
8
|
+
import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
8
9
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
9
10
|
import { getDispatchTurnDir, getTurnStagingResultPath } from '../lib/turn-paths.js';
|
|
10
11
|
|
|
@@ -51,6 +52,18 @@ export async function rejectTurnCommand(opts) {
|
|
|
51
52
|
console.log(chalk.red(`Turn rejected but dispatch bundle rewrite failed: ${bundleResult.error}`));
|
|
52
53
|
process.exit(1);
|
|
53
54
|
}
|
|
55
|
+
// BUG-51 follow-up: finalize the manifest so adapter verification matches
|
|
56
|
+
// fresh dispatches. reject-for-retry does not run after_dispatch hooks,
|
|
57
|
+
// so finalize immediately after writeDispatchBundle.
|
|
58
|
+
const manifestResult = finalizeDispatchManifest(root, validation.turn.turn_id, {
|
|
59
|
+
run_id: result.state.run_id,
|
|
60
|
+
role: validation.turn.assigned_role,
|
|
61
|
+
});
|
|
62
|
+
if (!manifestResult.ok) {
|
|
63
|
+
console.log(chalk.red(`Turn rejected but dispatch manifest failed: ${manifestResult.error}`));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
transitionActiveTurnLifecycle(root, validation.turn.turn_id, 'dispatched');
|
|
54
67
|
printDispatchBundleWarnings(bundleResult);
|
|
55
68
|
}
|
|
56
69
|
|
package/src/commands/restart.js
CHANGED
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
normalizeGovernedStateShape,
|
|
24
24
|
reconcileApprovalPausesWithConfig,
|
|
25
25
|
reconcileRecoveryActionsWithConfig,
|
|
26
|
+
transitionActiveTurnLifecycle,
|
|
26
27
|
STATE_PATH,
|
|
27
28
|
HISTORY_PATH,
|
|
28
29
|
LEDGER_PATH,
|
|
29
30
|
} from '../lib/governed-state.js';
|
|
30
31
|
import { writeDispatchBundle } from '../lib/dispatch-bundle.js';
|
|
32
|
+
import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
|
|
31
33
|
import { getDispatchTurnDir } from '../lib/turn-paths.js';
|
|
32
34
|
import { consumeNextApprovedIntent } from '../lib/intake.js';
|
|
33
35
|
import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
|
|
@@ -404,6 +406,19 @@ export async function restartCommand(opts) {
|
|
|
404
406
|
console.log(chalk.dim('Run `agentxchain reissue-turn` to reissue with a fresh bundle.'));
|
|
405
407
|
process.exit(1);
|
|
406
408
|
}
|
|
409
|
+
// BUG-51 follow-up: finalize the dispatch manifest so adapter-side
|
|
410
|
+
// verification matches fresh dispatches via `run`/`step`/`resume`.
|
|
411
|
+
// restart does not run after_dispatch hooks here, so finalize
|
|
412
|
+
// immediately after writeDispatchBundle.
|
|
413
|
+
const manifestResult = finalizeDispatchManifest(root, turnId, {
|
|
414
|
+
run_id: assignedState.run_id,
|
|
415
|
+
role: assignedRole,
|
|
416
|
+
});
|
|
417
|
+
if (!manifestResult.ok) {
|
|
418
|
+
console.log(chalk.red(`Turn assigned but dispatch manifest failed: ${manifestResult.error}`));
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
transitionActiveTurnLifecycle(root, turnId, 'dispatched');
|
|
407
422
|
for (const bw of bundleResult.warnings || []) {
|
|
408
423
|
console.log(chalk.yellow(`Dispatch bundle warning: ${bw}`));
|
|
409
424
|
}
|
package/src/commands/resume.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* - resolves target role from routing or --role override
|
|
9
9
|
* - if idle + no run_id → initializeGovernedRun() + assign
|
|
10
10
|
* - if paused + run_id exists → resume same run + assign
|
|
11
|
-
* - if
|
|
12
|
-
* - if active +
|
|
11
|
+
* - if blocked + retained active turn with failed status → re-dispatch same turn
|
|
12
|
+
* - if active + an active turn already exists → reject (no double assignment)
|
|
13
13
|
* - materializes a turn-scoped dispatch bundle under .agentxchain/dispatch/turns/<turn_id>/
|
|
14
14
|
* - exits without waiting for turn completion
|
|
15
15
|
*/
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
getActiveTurns,
|
|
27
27
|
getActiveTurnCount,
|
|
28
28
|
reactivateGovernedRun,
|
|
29
|
+
reconcilePhaseAdvanceBeforeDispatch,
|
|
30
|
+
transitionActiveTurnLifecycle,
|
|
29
31
|
STATE_PATH,
|
|
30
32
|
} from '../lib/governed-state.js';
|
|
31
33
|
import { writeDispatchBundle, getDispatchTurnDir, getTurnStagingResultPath } from '../lib/dispatch-bundle.js';
|
|
@@ -120,70 +122,25 @@ export async function resumeCommand(opts) {
|
|
|
120
122
|
process.exit(1);
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
// §47
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
retainedTurn = Object.values(activeTurns)[0];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const turnStatus = retainedTurn.status;
|
|
146
|
-
if (turnStatus === 'failed' || turnStatus === 'retrying') {
|
|
147
|
-
printResumeRunContext({ root, state, config });
|
|
148
|
-
console.log(chalk.yellow(`Re-dispatching failed turn: ${retainedTurn.turn_id}`));
|
|
149
|
-
console.log(` Role: ${retainedTurn.assigned_role}`);
|
|
150
|
-
console.log(` Attempt: ${retainedTurn.attempt}`);
|
|
151
|
-
console.log('');
|
|
152
|
-
|
|
153
|
-
const reactivated = reactivateGovernedRun(root, state, { via: turnResumeVia, notificationConfig: config });
|
|
154
|
-
if (!reactivated.ok) {
|
|
155
|
-
console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
|
|
156
|
-
process.exit(1);
|
|
157
|
-
}
|
|
158
|
-
state = reactivated.state;
|
|
159
|
-
if (reactivated.migration_notice) {
|
|
160
|
-
console.log(chalk.yellow(reactivated.migration_notice));
|
|
161
|
-
}
|
|
162
|
-
if (reactivated.phantom_notice) {
|
|
163
|
-
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Write dispatch bundle for the existing turn
|
|
167
|
-
const bundleResult = writeDispatchBundle(root, state, config);
|
|
168
|
-
if (!bundleResult.ok) {
|
|
169
|
-
console.log(chalk.red(`Failed to write dispatch bundle: ${bundleResult.error}`));
|
|
170
|
-
process.exit(1);
|
|
171
|
-
}
|
|
172
|
-
printDispatchBundleWarnings(bundleResult);
|
|
173
|
-
|
|
174
|
-
// after_dispatch hooks with bundle-core tamper protection
|
|
175
|
-
const hooksConfig = config.hooks || {};
|
|
176
|
-
if (hooksConfig.after_dispatch?.length > 0) {
|
|
177
|
-
const afterDispatchResult = runAfterDispatchHooks(root, hooksConfig, state, retainedTurn);
|
|
178
|
-
if (!afterDispatchResult.ok) {
|
|
179
|
-
process.exit(1);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
printDispatchSummary(state, config, retainedTurn);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
125
|
+
// Removed (Turn 25): the §47 `paused + retained turn → re-dispatch failed/retrying`
|
|
126
|
+
// branch is provably unreachable under the current schema and migration contract:
|
|
127
|
+
//
|
|
128
|
+
// 1. `cli/src/lib/schema.js:184` rejects `status: 'paused'` unless
|
|
129
|
+
// `pending_phase_transition` or `pending_run_completion` is set.
|
|
130
|
+
// 2. The guard above (line 119) short-circuits with `printRecoverySummary`
|
|
131
|
+
// whenever either pending field is set — so any schema-valid paused state
|
|
132
|
+
// exits before reaching this point.
|
|
133
|
+
// 3. Legacy on-disk shapes that pre-date the schema constraint (paused +
|
|
134
|
+
// `blocked_on: 'human:...'` / `blocked_on: 'escalation:...'` with no
|
|
135
|
+
// pending approval) are auto-migrated to `status: 'blocked'` by
|
|
136
|
+
// `normalizeStateForRead` in `governed-state.js:2191-2204` before
|
|
137
|
+
// `loadProjectState` returns.
|
|
138
|
+
//
|
|
139
|
+
// The reachable retained-turn re-dispatch path is the `blocked + activeCount > 0`
|
|
140
|
+
// branch immediately below, which legacy paused-pause shapes are migrated into.
|
|
141
|
+
// Per `DEC-UNREACHABLE-BRANCH-COVERAGE-001`, dead branches are removed (not
|
|
142
|
+
// patched defensively) once the schema citation + migration citation are
|
|
143
|
+
// documented in code and the coverage matrix.
|
|
187
144
|
|
|
188
145
|
if (state.status === 'blocked' && activeCount > 0) {
|
|
189
146
|
let retainedTurn = null;
|
|
@@ -244,6 +201,21 @@ export async function resumeCommand(opts) {
|
|
|
244
201
|
}
|
|
245
202
|
}
|
|
246
203
|
|
|
204
|
+
// BUG-51 follow-up: see comment in paused/failed retained-turn branch.
|
|
205
|
+
// The blocked re-dispatch path has the same watchdog/manifest invariant.
|
|
206
|
+
const manifestResult = finalizeDispatchManifest(root, retainedTurn.turn_id, {
|
|
207
|
+
run_id: state.run_id,
|
|
208
|
+
role: retainedTurn.assigned_role,
|
|
209
|
+
});
|
|
210
|
+
if (!manifestResult.ok) {
|
|
211
|
+
console.log(chalk.red(`Failed to finalize dispatch manifest: ${manifestResult.error}`));
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
const dispatched = transitionActiveTurnLifecycle(root, retainedTurn.turn_id, 'dispatched');
|
|
215
|
+
if (dispatched.ok) {
|
|
216
|
+
state = dispatched.state;
|
|
217
|
+
}
|
|
218
|
+
|
|
247
219
|
printDispatchSummary(state, config, retainedTurn);
|
|
248
220
|
return;
|
|
249
221
|
}
|
|
@@ -299,6 +271,24 @@ export async function resumeCommand(opts) {
|
|
|
299
271
|
}
|
|
300
272
|
}
|
|
301
273
|
|
|
274
|
+
const phaseReconciliation = reconcilePhaseAdvanceBeforeDispatch(root, config, state);
|
|
275
|
+
if (!phaseReconciliation.ok && !phaseReconciliation.state) {
|
|
276
|
+
console.log(chalk.red(`Failed to reconcile phase gate before dispatch: ${phaseReconciliation.error}`));
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
state = phaseReconciliation.state || state;
|
|
280
|
+
if (phaseReconciliation.advanced) {
|
|
281
|
+
console.log(chalk.green(`Advanced phase before dispatch: ${phaseReconciliation.from_phase} → ${phaseReconciliation.to_phase}`));
|
|
282
|
+
}
|
|
283
|
+
if (state.pending_phase_transition || state.pending_run_completion) {
|
|
284
|
+
printRecoverySummary(state, 'This run is awaiting approval.', config);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
if (state.status === 'blocked') {
|
|
288
|
+
printRecoverySummary(state, 'This run is blocked.', config);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
302
292
|
// Print run-context header before dispatch
|
|
303
293
|
printResumeRunContext({ root, state, config });
|
|
304
294
|
|
|
@@ -360,6 +350,11 @@ export async function resumeCommand(opts) {
|
|
|
360
350
|
process.exit(1);
|
|
361
351
|
}
|
|
362
352
|
|
|
353
|
+
const dispatched = transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
|
|
354
|
+
if (dispatched.ok) {
|
|
355
|
+
state = dispatched.state;
|
|
356
|
+
}
|
|
357
|
+
|
|
363
358
|
printDispatchSummary(state, config);
|
|
364
359
|
}
|
|
365
360
|
|
package/src/commands/run.js
CHANGED
|
@@ -18,6 +18,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
|
18
18
|
import { join } from 'path';
|
|
19
19
|
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
20
20
|
import { runLoop } from '../lib/run-loop.js';
|
|
21
|
+
import { transitionActiveTurnLifecycle } from '../lib/runner-interface.js';
|
|
21
22
|
import { buildRunExport } from '../lib/export.js';
|
|
22
23
|
import { buildGovernanceReport, formatGovernanceReportMarkdown } from '../lib/report.js';
|
|
23
24
|
import { validateParentRun } from '../lib/run-history.js';
|
|
@@ -49,6 +50,8 @@ import { resolveContinuousOptions, executeContinuousRun } from '../lib/continuou
|
|
|
49
50
|
import { createDispatchProgressTracker } from '../lib/dispatch-progress.js';
|
|
50
51
|
import { emitRunEvent } from '../lib/run-events.js';
|
|
51
52
|
import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
|
|
53
|
+
import { failTurnStartup } from '../lib/stale-turn-watchdog.js';
|
|
54
|
+
import { hasMinimumTurnResultShape } from '../lib/turn-result-shape.js';
|
|
52
55
|
|
|
53
56
|
export async function runCommand(opts) {
|
|
54
57
|
const context = loadProjectContext();
|
|
@@ -314,20 +317,49 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
314
317
|
if (!manifestResult.ok) {
|
|
315
318
|
return { accept: false, reason: `dispatch manifest failed: ${manifestResult.error}` };
|
|
316
319
|
}
|
|
320
|
+
transitionActiveTurnLifecycle(projectRoot, turn.turn_id, 'dispatched');
|
|
317
321
|
|
|
318
322
|
// ── Route to adapter ──────────────────────────────────────────────
|
|
319
323
|
const tracker = createDispatchProgressTracker(projectRoot, turn, {
|
|
320
324
|
adapter_type: runtimeType,
|
|
321
325
|
});
|
|
326
|
+
let startupStarted = false;
|
|
327
|
+
let runningMarked = false;
|
|
328
|
+
|
|
329
|
+
const ensureStartingState = (pid = null, at = new Date().toISOString()) => {
|
|
330
|
+
if (startupStarted) return;
|
|
331
|
+
startupStarted = true;
|
|
332
|
+
transitionActiveTurnLifecycle(projectRoot, turn.turn_id, 'starting', { pid, at });
|
|
333
|
+
tracker.start();
|
|
334
|
+
if (pid != null) {
|
|
335
|
+
tracker.setPid(pid);
|
|
336
|
+
}
|
|
337
|
+
emitRunEvent(projectRoot, 'dispatch_progress', {
|
|
338
|
+
run_id: state.run_id,
|
|
339
|
+
phase: state.phase,
|
|
340
|
+
status: state.status,
|
|
341
|
+
turn: { turn_id: turn.turn_id, assigned_role: roleId },
|
|
342
|
+
payload: { milestone: 'started', output_lines: 0, elapsed_seconds: 0, silent_seconds: 0 },
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
|
|
347
|
+
if (runningMarked) return;
|
|
348
|
+
runningMarked = true;
|
|
349
|
+
transitionActiveTurnLifecycle(projectRoot, turn.turn_id, 'running', { stream, at });
|
|
350
|
+
};
|
|
322
351
|
|
|
323
352
|
const adapterOpts = {
|
|
324
353
|
signal: combineAbortSignals(controller.signal, ctx.dispatchAbortSignal),
|
|
325
354
|
onStatus: (msg) => log(chalk.dim(` ${msg}`)),
|
|
326
355
|
verifyManifest: true,
|
|
327
356
|
turnId: turn.turn_id,
|
|
357
|
+
onSpawnAttached: ({ pid, at }) => ensureStartingState(pid, at),
|
|
358
|
+
onFirstOutput: ({ at, stream }) => ensureRunningState(stream, at),
|
|
328
359
|
};
|
|
329
360
|
|
|
330
361
|
const recordOutputActivity = (stream, text) => {
|
|
362
|
+
ensureRunningState(stream);
|
|
331
363
|
const lines = text.split('\n').length - 1 || 1;
|
|
332
364
|
const wasSilent = tracker.onOutput(stream, lines);
|
|
333
365
|
if (wasSilent) {
|
|
@@ -368,23 +400,17 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
368
400
|
|
|
369
401
|
let adapterResult;
|
|
370
402
|
|
|
371
|
-
// Emit dispatch_progress started event and begin tracking
|
|
372
|
-
tracker.start();
|
|
373
|
-
emitRunEvent(projectRoot, 'dispatch_progress', {
|
|
374
|
-
run_id: state.run_id,
|
|
375
|
-
phase: state.phase,
|
|
376
|
-
status: state.status,
|
|
377
|
-
turn: { turn_id: turn.turn_id, assigned_role: roleId },
|
|
378
|
-
payload: { milestone: 'started', output_lines: 0, elapsed_seconds: 0, silent_seconds: 0 },
|
|
379
|
-
});
|
|
380
|
-
|
|
381
403
|
try {
|
|
382
404
|
if (runtimeType === 'api_proxy') {
|
|
405
|
+
ensureStartingState(null);
|
|
406
|
+
ensureRunningState('request');
|
|
383
407
|
log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
|
|
384
408
|
tracker.requestStarted();
|
|
385
409
|
adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
|
|
386
410
|
if (adapterResult.ok) tracker.responseReceived();
|
|
387
411
|
} else if (runtimeType === 'mcp') {
|
|
412
|
+
ensureStartingState(null);
|
|
413
|
+
ensureRunningState('request');
|
|
388
414
|
const transport = resolveMcpTransport(runtime);
|
|
389
415
|
log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
|
|
390
416
|
tracker.requestStarted();
|
|
@@ -395,6 +421,8 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
395
421
|
log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
|
|
396
422
|
adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
|
|
397
423
|
} else if (runtimeType === 'remote_agent') {
|
|
424
|
+
ensureStartingState(null);
|
|
425
|
+
ensureRunningState('request');
|
|
398
426
|
log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
|
|
399
427
|
tracker.requestStarted();
|
|
400
428
|
adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
|
|
@@ -413,6 +441,10 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
413
441
|
throw err;
|
|
414
442
|
}
|
|
415
443
|
|
|
444
|
+
if (adapterResult.ok && runtimeType === 'local_cli' && !runningMarked) {
|
|
445
|
+
ensureRunningState('staged_result', adapterResult.firstOutputAt || new Date().toISOString());
|
|
446
|
+
}
|
|
447
|
+
|
|
416
448
|
// Emit completion/failure progress event and clean up tracker
|
|
417
449
|
const progressState = tracker.getState();
|
|
418
450
|
const elapsedSec = Math.round((Date.now() - new Date(progressState.started_at)) / 1000);
|
|
@@ -439,6 +471,19 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
439
471
|
return { accept: false, reason: 'dispatch timed out' };
|
|
440
472
|
}
|
|
441
473
|
|
|
474
|
+
if (adapterResult.startupFailure) {
|
|
475
|
+
const freshState = loadProjectState(projectRoot, cfg) || state;
|
|
476
|
+
failTurnStartup(projectRoot, freshState, cfg, turn.turn_id, {
|
|
477
|
+
failure_type: adapterResult.startupFailureType || 'no_subprocess_output',
|
|
478
|
+
threshold_ms: cfg?.run_loop?.startup_watchdog_ms ?? 30_000,
|
|
479
|
+
running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
|
|
480
|
+
? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
|
|
481
|
+
: 0,
|
|
482
|
+
recommendation: `Turn ${turn.turn_id} failed to start within the startup watchdog window. Run \`agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost\` to recover.`,
|
|
483
|
+
});
|
|
484
|
+
return { accept: false, blocked: true, reason: adapterResult.error || 'turn startup failed' };
|
|
485
|
+
}
|
|
486
|
+
|
|
442
487
|
// Adapter failure
|
|
443
488
|
if (!adapterResult.ok) {
|
|
444
489
|
if (shouldSuggestManualQaFallback({
|
|
@@ -472,6 +517,18 @@ export async function executeGovernedRun(context, opts = {}) {
|
|
|
472
517
|
return { accept: false, reason: `failed to parse staged result: ${err.message}` };
|
|
473
518
|
}
|
|
474
519
|
|
|
520
|
+
// Per DEC-MINIMUM-TURN-RESULT-SHAPE-001: the staged-result read shortcut
|
|
521
|
+
// must refuse payloads that lack the minimum governed envelope. Adapter
|
|
522
|
+
// pre-stage guards already reject these, but this is the final boundary
|
|
523
|
+
// before acceptance projection — fail closed on tampered or legacy
|
|
524
|
+
// adapter output rather than trust upstream.
|
|
525
|
+
if (!hasMinimumTurnResultShape(turnResult)) {
|
|
526
|
+
return {
|
|
527
|
+
accept: false,
|
|
528
|
+
reason: 'staged result missing minimum governed envelope (schema_version + identity + lifecycle fields)',
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
475
532
|
return { accept: true, turnResult };
|
|
476
533
|
},
|
|
477
534
|
|
package/src/commands/schedule.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { loadProjectContext } from '../lib/config.js';
|
|
2
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
3
3
|
import {
|
|
4
4
|
SCHEDULE_STATE_PATH,
|
|
5
5
|
DAEMON_STATE_PATH,
|
|
@@ -97,19 +97,37 @@ function buildScheduleProvenance(entry) {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function buildScheduleExecutionResult(entryId, execution, fallbackState, action = 'ran') {
|
|
101
|
-
const state = execution.result?.state ||
|
|
100
|
+
export function buildScheduleExecutionResult(entryId, execution, fallbackState, action = 'ran') {
|
|
101
|
+
const state = fallbackState || execution.result?.state || null;
|
|
102
|
+
const blockedReason = state?.blocked_reason || null;
|
|
103
|
+
const recoveryAction = blockedReason?.recovery?.recovery_action || null;
|
|
104
|
+
const blockedCategory = blockedReason?.category || null;
|
|
102
105
|
return {
|
|
103
106
|
id: entryId,
|
|
104
107
|
action,
|
|
105
108
|
run_id: state?.run_id || null,
|
|
106
109
|
stop_reason: execution.result?.stop_reason || null,
|
|
107
110
|
exit_code: execution.exitCode,
|
|
111
|
+
recovery_action: recoveryAction,
|
|
112
|
+
blocked_category: blockedCategory,
|
|
108
113
|
};
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
function resolveScheduleExecutionState(root, config, execution, fallbackState) {
|
|
117
|
+
const executionState = execution.result?.state || null;
|
|
118
|
+
const liveState = loadProjectState(root, config);
|
|
119
|
+
|
|
120
|
+
if (execution.result?.stop_reason === 'blocked' || execution.result?.stop_reason === 'reject_exhausted') {
|
|
121
|
+
if (liveState?.status === 'blocked' && liveState?.blocked_reason) {
|
|
122
|
+
return liveState;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return executionState || liveState || fallbackState || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
function recordScheduleExecution(context, entryId, execution, fallbackState, nowIso, action = 'ran') {
|
|
112
|
-
const state =
|
|
130
|
+
const state = resolveScheduleExecutionState(context.root, context.config, execution, fallbackState);
|
|
113
131
|
const runId = state?.run_id || null;
|
|
114
132
|
const startedAt = state?.created_at || nowIso;
|
|
115
133
|
|
|
@@ -197,7 +215,7 @@ async function continueActiveScheduledRun(context, opts = {}) {
|
|
|
197
215
|
}
|
|
198
216
|
|
|
199
217
|
const blocked = execution.result?.stop_reason === 'blocked';
|
|
200
|
-
const action = blocked
|
|
218
|
+
const action = blocked ? 'blocked' : 'continued';
|
|
201
219
|
const result = recordScheduleExecution(context, scheduleId, execution, state, opts.at || new Date().toISOString(), action);
|
|
202
220
|
|
|
203
221
|
if (execution.exitCode !== 0 && !(opts.tolerateBlockedRun && blocked)) {
|
|
@@ -312,7 +330,7 @@ async function runDueSchedules(context, opts = {}) {
|
|
|
312
330
|
execution,
|
|
313
331
|
execution.result?.state || null,
|
|
314
332
|
nowIso,
|
|
315
|
-
blocked
|
|
333
|
+
blocked ? 'blocked' : 'ran',
|
|
316
334
|
));
|
|
317
335
|
|
|
318
336
|
if (execution.exitCode !== 0) {
|
|
@@ -489,6 +507,8 @@ async function advanceScheduleContinuousSession(context, entry, opts = {}) {
|
|
|
489
507
|
run_id: step.run_id || null,
|
|
490
508
|
intent_id: step.intent_id || null,
|
|
491
509
|
runs_completed: session.runs_completed,
|
|
510
|
+
recovery_action: step.recovery_action || null,
|
|
511
|
+
blocked_category: step.blocked_category || null,
|
|
492
512
|
};
|
|
493
513
|
}
|
|
494
514
|
|
|
@@ -536,7 +556,12 @@ export async function scheduleRunDueCommand(opts) {
|
|
|
536
556
|
} else if (entry.action === 'preemption_failed') {
|
|
537
557
|
console.log(chalk.red(`Schedule preemption failed: ${entry.id} (${entry.error || 'unknown error'})`));
|
|
538
558
|
} else if (entry.action === 'blocked') {
|
|
539
|
-
|
|
559
|
+
if (entry.recovery_action) {
|
|
560
|
+
const categorySuffix = entry.blocked_category ? ` (${entry.blocked_category})` : '';
|
|
561
|
+
console.log(chalk.yellow(`Schedule blocked: ${entry.id}${categorySuffix}. Recovery: ${entry.recovery_action}`));
|
|
562
|
+
} else {
|
|
563
|
+
console.log(chalk.yellow(`Schedule waiting on unblock: ${entry.id}`));
|
|
564
|
+
}
|
|
540
565
|
} else if (entry.action === 'skipped') {
|
|
541
566
|
console.log(chalk.yellow(`Schedule skipped: ${entry.id} (${entry.reason})`));
|
|
542
567
|
} else if (entry.action === 'not_due') {
|
|
@@ -709,6 +734,8 @@ export async function scheduleDaemonCommand(opts) {
|
|
|
709
734
|
runs_completed: contResult.runs_completed ?? null,
|
|
710
735
|
};
|
|
711
736
|
if (contResult.reason) contResultEntry.reason = contResult.reason;
|
|
737
|
+
if (contResult.recovery_action) contResultEntry.recovery_action = contResult.recovery_action;
|
|
738
|
+
if (contResult.blocked_category) contResultEntry.blocked_category = contResult.blocked_category;
|
|
712
739
|
|
|
713
740
|
result = {
|
|
714
741
|
ok: contResult.ok !== false && nonContResult.ok,
|