agentxchain 2.146.0 → 2.147.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.146.0",
3
+ "version": "2.147.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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}"
@@ -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/4] Checking npm registry..."
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/4] Syncing repo mirror to published tarball..."
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: Run the full test suite WITHOUT the preflight skip
113
- echo "[4/5] Running full test suite (no preflight skip)..."
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
 
@@ -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
  }
@@ -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 paused + current_turn with failed status → re-dispatch same turn
12
- * - if active + current_turn exists → reject (no double assignment)
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: paused + retained turn with failed/retrying status → re-dispatch same turn
124
- if (state.status === 'paused' && activeCount > 0) {
125
- // Resolve which turn to re-dispatch
126
- let retainedTurn = null;
127
- if (opts.turn) {
128
- retainedTurn = activeTurns[opts.turn];
129
- if (!retainedTurn) {
130
- console.log(chalk.red(`No active turn found for --turn ${opts.turn}`));
131
- process.exit(1);
132
- }
133
- } else if (activeCount > 1) {
134
- console.log(chalk.red('Multiple retained turns exist. Use --turn <id> to specify which to re-dispatch.'));
135
- for (const turn of Object.values(activeTurns)) {
136
- console.log(` ${chalk.yellow('●')} ${turn.turn_id} — ${chalk.bold(turn.assigned_role)} (${turn.status})`);
137
- }
138
- console.log('');
139
- console.log(chalk.dim('Example: agentxchain resume --turn <turn_id>'));
140
- process.exit(1);
141
- } else {
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
 
@@ -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
 
@@ -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 || fallbackState || null;
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 = execution.result?.state || fallbackState || null;
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 && opts.tolerateBlockedRun ? 'blocked' : 'continued';
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 && opts.tolerateBlockedRun ? 'blocked' : 'ran',
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
- console.log(chalk.yellow(`Schedule waiting on unblock: ${entry.id}`));
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,