agentxchain 2.130.0 → 2.131.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 +3 -2
- package/scripts/check-release-alignment.mjs +27 -1
- package/scripts/release-preflight.sh +56 -6
- package/src/commands/connector.js +1 -0
- package/src/commands/doctor.js +8 -19
- package/src/commands/mission.js +108 -5
- package/src/lib/connector-probe.js +42 -27
- package/src/lib/connector-validate.js +21 -0
- package/src/lib/dashboard/plan-reader.js +11 -0
- package/src/lib/governed-state.js +2 -1
- package/src/lib/mission-plans.js +259 -2
- package/src/lib/release-alignment.js +21 -1
- package/src/lib/run-events.js +1 -0
- package/src/lib/runtime-spawn-context.js +163 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentxchain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.131.0",
|
|
4
4
|
"description": "CLI for AgentXchain — governed multi-agent software delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"dev": "node bin/agentxchain.js",
|
|
25
25
|
"test": "npm run test:vitest && npm run test:node",
|
|
26
26
|
"test:vitest": "vitest run --reporter=verbose",
|
|
27
|
-
"test:
|
|
27
|
+
"test:beta": "node --test test/beta-tester-scenarios/*.test.js",
|
|
28
|
+
"test:node": "node --test test/*.test.js test/beta-tester-scenarios/*.test.js",
|
|
28
29
|
"preflight:release": "bash scripts/release-preflight.sh",
|
|
29
30
|
"preflight:release:strict": "bash scripts/release-preflight.sh --strict",
|
|
30
31
|
"check:release-alignment": "node scripts/check-release-alignment.mjs",
|
|
@@ -12,12 +12,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
12
12
|
const REPO_ROOT = join(__dirname, '..', '..');
|
|
13
13
|
|
|
14
14
|
function usage() {
|
|
15
|
-
console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json]');
|
|
15
|
+
console.error('Usage: node cli/scripts/check-release-alignment.mjs [--target-version <semver>] [--scope prebump|current] [--json|--report]');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
let targetVersion = null;
|
|
19
19
|
let scope = RELEASE_ALIGNMENT_SCOPES.CURRENT;
|
|
20
20
|
let json = false;
|
|
21
|
+
let report = false;
|
|
21
22
|
|
|
22
23
|
for (let index = 2; index < process.argv.length; index += 1) {
|
|
23
24
|
const arg = process.argv[index];
|
|
@@ -45,15 +46,40 @@ for (let index = 2; index < process.argv.length; index += 1) {
|
|
|
45
46
|
json = true;
|
|
46
47
|
continue;
|
|
47
48
|
}
|
|
49
|
+
if (arg === '--report') {
|
|
50
|
+
report = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
48
53
|
console.error(`Error: unknown argument "${arg}"`);
|
|
49
54
|
usage();
|
|
50
55
|
process.exit(1);
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
if (json && report) {
|
|
59
|
+
console.error('Error: --json and --report are mutually exclusive');
|
|
60
|
+
usage();
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
const result = validateReleaseAlignment(REPO_ROOT, { targetVersion, scope });
|
|
54
65
|
|
|
55
66
|
if (json) {
|
|
56
67
|
console.log(JSON.stringify(result, null, 2));
|
|
68
|
+
} else if (report) {
|
|
69
|
+
const readyCount = result.surfaceResults.filter((surface) => surface.ok).length;
|
|
70
|
+
const needsUpdateCount = result.surfaceResults.length - readyCount;
|
|
71
|
+
console.log(
|
|
72
|
+
`Release alignment report for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`,
|
|
73
|
+
);
|
|
74
|
+
for (const surface of result.surfaceResults) {
|
|
75
|
+
console.log(`- [${surface.ok ? 'ready' : 'needs update'}] (${surface.surface_id}) ${surface.label}`);
|
|
76
|
+
for (const error of surface.errors) {
|
|
77
|
+
console.log(` - ${error}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log(
|
|
81
|
+
`Summary: ${readyCount} ready, ${needsUpdateCount} need update.`,
|
|
82
|
+
);
|
|
57
83
|
} else if (result.ok) {
|
|
58
84
|
console.log(`Release alignment OK for ${result.targetVersion} (${result.scope}, ${result.checkedSurfaceCount} surfaces).`);
|
|
59
85
|
} else {
|
|
@@ -10,11 +10,13 @@ cd "$CLI_DIR"
|
|
|
10
10
|
|
|
11
11
|
STRICT_MODE=0
|
|
12
12
|
PUBLISH_GATE=0
|
|
13
|
+
DRY_RUN=0
|
|
13
14
|
TARGET_VERSION="2.0.0"
|
|
14
15
|
|
|
15
16
|
usage() {
|
|
16
|
-
echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--target-version <semver>]" >&2
|
|
17
|
+
echo "Usage: bash scripts/release-preflight.sh [--strict] [--publish-gate] [--dry-run] [--target-version <semver>]" >&2
|
|
17
18
|
echo " --publish-gate Run only release-critical checks (no full test suite). Use in CI publish workflows." >&2
|
|
19
|
+
echo " --dry-run Preview manual release-alignment surfaces without running the full gate." >&2
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
while [[ $# -gt 0 ]]; do
|
|
@@ -28,6 +30,10 @@ while [[ $# -gt 0 ]]; do
|
|
|
28
30
|
STRICT_MODE=1
|
|
29
31
|
shift
|
|
30
32
|
;;
|
|
33
|
+
--dry-run)
|
|
34
|
+
DRY_RUN=1
|
|
35
|
+
shift
|
|
36
|
+
;;
|
|
31
37
|
--target-version)
|
|
32
38
|
if [[ -z "${2:-}" ]]; then
|
|
33
39
|
echo "Error: --target-version requires a semver argument" >&2
|
|
@@ -49,6 +55,12 @@ while [[ $# -gt 0 ]]; do
|
|
|
49
55
|
esac
|
|
50
56
|
done
|
|
51
57
|
|
|
58
|
+
if [[ "$DRY_RUN" -eq 1 && "$STRICT_MODE" -eq 1 ]]; then
|
|
59
|
+
echo "Error: --dry-run cannot be combined with --strict or --publish-gate" >&2
|
|
60
|
+
usage
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
52
64
|
PASS=0
|
|
53
65
|
FAIL=0
|
|
54
66
|
WARN=0
|
|
@@ -84,6 +96,31 @@ else
|
|
|
84
96
|
fi
|
|
85
97
|
echo ""
|
|
86
98
|
|
|
99
|
+
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
100
|
+
echo "Release Preflight Preview"
|
|
101
|
+
echo "========================="
|
|
102
|
+
echo "Mode: DRY RUN (manual release-alignment surfaces only; no git/npm gate checks executed)"
|
|
103
|
+
echo ""
|
|
104
|
+
ALIGNMENT_SCRIPT="${SCRIPT_DIR}/check-release-alignment.mjs"
|
|
105
|
+
if [[ ! -f "$ALIGNMENT_SCRIPT" ]]; then
|
|
106
|
+
echo "Error: release alignment preview requires ${ALIGNMENT_SCRIPT}" >&2
|
|
107
|
+
exit 1
|
|
108
|
+
fi
|
|
109
|
+
if run_and_capture ALIGNMENT_REPORT node "$ALIGNMENT_SCRIPT" --scope prebump --target-version "$TARGET_VERSION" --report; then
|
|
110
|
+
ALIGNMENT_STATUS=0
|
|
111
|
+
else
|
|
112
|
+
ALIGNMENT_STATUS=$?
|
|
113
|
+
fi
|
|
114
|
+
printf '%s\n' "$ALIGNMENT_REPORT"
|
|
115
|
+
echo ""
|
|
116
|
+
if [[ "$ALIGNMENT_STATUS" -eq 0 ]]; then
|
|
117
|
+
echo "PREVIEW COMPLETE: manual release-alignment surfaces are ready for ${TARGET_VERSION}."
|
|
118
|
+
else
|
|
119
|
+
echo "PREVIEW COMPLETE: manual release-alignment surfaces still need updates before a real preflight/tag push."
|
|
120
|
+
fi
|
|
121
|
+
exit 0
|
|
122
|
+
fi
|
|
123
|
+
|
|
87
124
|
# 1. Clean working tree
|
|
88
125
|
echo "[1/7] Git status"
|
|
89
126
|
if git diff --quiet HEAD 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
|
@@ -110,24 +147,37 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
|
|
|
110
147
|
echo "[3/7] Release-gate tests (targeted subset)"
|
|
111
148
|
# In publish-gate mode, run only release-critical tests to avoid CI hangs.
|
|
112
149
|
# The full test suite is a pre-tag responsibility, not a publish-time gate.
|
|
113
|
-
|
|
150
|
+
GATE_TEST_PATTERNS=(
|
|
114
151
|
test/release-preflight.test.js
|
|
115
152
|
test/release-docs-content.test.js
|
|
116
153
|
test/release-notes-gate.test.js
|
|
117
154
|
test/release-identity-hardening.test.js
|
|
118
155
|
test/normalized-config.test.js
|
|
119
156
|
test/conformance.test.js
|
|
157
|
+
test/beta-tester-scenarios/*.test.js
|
|
120
158
|
)
|
|
121
159
|
GATE_TEST_ARGS=()
|
|
122
|
-
|
|
123
|
-
|
|
160
|
+
shopt -s nullglob
|
|
161
|
+
for pattern in "${GATE_TEST_PATTERNS[@]}"; do
|
|
162
|
+
for t in $pattern; do
|
|
124
163
|
GATE_TEST_ARGS+=("$t")
|
|
125
|
-
|
|
164
|
+
done
|
|
126
165
|
done
|
|
166
|
+
shopt -u nullglob
|
|
127
167
|
if [[ ${#GATE_TEST_ARGS[@]} -eq 0 ]]; then
|
|
128
168
|
fail "No release-gate test files found"
|
|
129
169
|
else
|
|
130
|
-
|
|
170
|
+
BETA_TEST_COUNT=0
|
|
171
|
+
for t in "${GATE_TEST_ARGS[@]}"; do
|
|
172
|
+
if [[ "$t" == test/beta-tester-scenarios/*.test.js ]]; then
|
|
173
|
+
BETA_TEST_COUNT=$((BETA_TEST_COUNT + 1))
|
|
174
|
+
fi
|
|
175
|
+
done
|
|
176
|
+
if [[ "$BETA_TEST_COUNT" -eq 0 ]]; then
|
|
177
|
+
fail "No beta-tester scenario tests found for release-gate verification"
|
|
178
|
+
TEST_OUTPUT=""
|
|
179
|
+
TEST_STATUS=1
|
|
180
|
+
elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
|
|
131
181
|
TEST_STATUS=0
|
|
132
182
|
else
|
|
133
183
|
TEST_STATUS=$?
|
|
@@ -105,6 +105,7 @@ export async function connectorCheckCommand(runtimeId, options = {}) {
|
|
|
105
105
|
const result = await probeConfiguredConnectors(context.config, {
|
|
106
106
|
runtimeId: runtimeId || null,
|
|
107
107
|
timeoutMs,
|
|
108
|
+
root: context.root,
|
|
108
109
|
onProbeStart: options.json ? null : (probeRuntimeId, runtime) => {
|
|
109
110
|
console.log(` ${chalk.dim('…')} Probing ${chalk.bold(probeRuntimeId)} ${chalk.dim(`(${runtime.type})`)}`);
|
|
110
111
|
},
|
package/src/commands/doctor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
|
-
import { execFileSync
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { loadConfig, loadLock, findProjectRoot, loadProjectState } from '../lib/config.js';
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/governed-state.js';
|
|
21
21
|
import { findPendingApprovedIntents } from '../lib/intake.js';
|
|
22
22
|
import { checkCleanBaseline } from '../lib/repo-observer.js';
|
|
23
|
+
import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
|
|
23
24
|
|
|
24
25
|
export async function doctorCommand(opts = {}) {
|
|
25
26
|
const root = findProjectRoot(process.cwd());
|
|
@@ -90,7 +91,7 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
90
91
|
const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
|
|
91
92
|
const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
|
|
92
93
|
for (const [rtId, rt] of Object.entries(runtimes)) {
|
|
93
|
-
const check = checkRuntimeReachable(rtId, rt, rolesByRuntime[rtId] || []);
|
|
94
|
+
const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
|
|
94
95
|
checks.push(check);
|
|
95
96
|
}
|
|
96
97
|
const connectorProbe = getConnectorProbeRecommendation(runtimes);
|
|
@@ -484,7 +485,7 @@ function buildCliVersionCheck(cliVersionHealth) {
|
|
|
484
485
|
};
|
|
485
486
|
}
|
|
486
487
|
|
|
487
|
-
function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
|
|
488
|
+
function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
|
|
488
489
|
const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
|
|
489
490
|
|
|
490
491
|
if (!rt || !rt.type) {
|
|
@@ -496,14 +497,8 @@ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
|
|
|
496
497
|
return attachRuntimeContract({ ...base, level: 'pass', detail: 'Manual runtime (no binary needed)' }, rtId, rt, boundRoleEntries);
|
|
497
498
|
|
|
498
499
|
case 'local_cli': {
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
try {
|
|
502
|
-
execSync(`command -v ${cmd}`, { stdio: 'ignore' });
|
|
503
|
-
return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
|
|
504
|
-
} catch {
|
|
505
|
-
return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
|
|
506
|
-
}
|
|
500
|
+
const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
|
|
501
|
+
return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
|
|
507
502
|
}
|
|
508
503
|
|
|
509
504
|
case 'api_proxy': {
|
|
@@ -523,14 +518,8 @@ function checkRuntimeReachable(rtId, rt, boundRoleEntries = []) {
|
|
|
523
518
|
if (transport === 'streamable_http') {
|
|
524
519
|
return attachRuntimeContract({ ...base, level: 'warn', detail: 'Remote MCP endpoint (cannot verify at doctor time)' }, rtId, rt, boundRoleEntries);
|
|
525
520
|
}
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
try {
|
|
529
|
-
execSync(`command -v ${cmd}`, { stdio: 'ignore' });
|
|
530
|
-
return attachRuntimeContract({ ...base, level: 'pass', detail: `${cmd} binary found` }, rtId, rt, boundRoleEntries);
|
|
531
|
-
} catch {
|
|
532
|
-
return attachRuntimeContract({ ...base, level: 'fail', detail: `${cmd} not found in PATH` }, rtId, rt, boundRoleEntries);
|
|
533
|
-
}
|
|
521
|
+
const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
|
|
522
|
+
return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
|
|
534
523
|
}
|
|
535
524
|
|
|
536
525
|
case 'remote_agent':
|
package/src/commands/mission.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getWorkstreamStatusSummary,
|
|
23
23
|
launchCoordinatorWorkstream,
|
|
24
24
|
launchWorkstream,
|
|
25
|
+
retryCoordinatorWorkstream,
|
|
25
26
|
retryWorkstream,
|
|
26
27
|
markWorkstreamOutcome,
|
|
27
28
|
loadAllPlans,
|
|
@@ -541,11 +542,6 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
|
|
|
541
542
|
}
|
|
542
543
|
|
|
543
544
|
if (mission.coordinator && plan.coordinator_scope) {
|
|
544
|
-
if (opts.retry) {
|
|
545
|
-
console.error(chalk.red('--retry is not supported for coordinator-bound mission plans yet.'));
|
|
546
|
-
process.exit(1);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
545
|
const coordinatorConfigResult = loadCoordinatorConfig(mission.coordinator.workspace_path);
|
|
550
546
|
if (!coordinatorConfigResult.ok) {
|
|
551
547
|
console.error(chalk.red('Coordinator config validation failed:'));
|
|
@@ -565,6 +561,113 @@ export async function missionPlanLaunchCommand(planTarget, opts) {
|
|
|
565
561
|
process.exit(1);
|
|
566
562
|
}
|
|
567
563
|
|
|
564
|
+
if (opts.retry) {
|
|
565
|
+
const retry = retryCoordinatorWorkstream(
|
|
566
|
+
root,
|
|
567
|
+
mission,
|
|
568
|
+
plan.plan_id,
|
|
569
|
+
opts.workstream,
|
|
570
|
+
coordinatorConfigResult.config,
|
|
571
|
+
{
|
|
572
|
+
reason: `mission plan retry ${opts.workstream}`,
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
if (!retry.ok) {
|
|
576
|
+
console.error(chalk.red(retry.error));
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const executor = opts._executeGovernedRun || executeGovernedRun;
|
|
581
|
+
const repoContext = loadProjectContext(retry.retryResult.repo_path);
|
|
582
|
+
if (!repoContext) {
|
|
583
|
+
console.error(chalk.red(`Cannot load project context for retried repo at ${retry.retryResult.repo_path}.`));
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const runOpts = {
|
|
588
|
+
autoApprove: !!opts.autoApprove,
|
|
589
|
+
provenance: {
|
|
590
|
+
trigger: 'manual',
|
|
591
|
+
created_by: 'operator',
|
|
592
|
+
trigger_reason: `mission:${mission.mission_id} workstream:${opts.workstream} coordinator-retry:${retry.retryResult.repo_id}`,
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
let execution;
|
|
597
|
+
try {
|
|
598
|
+
execution = await executor(repoContext, runOpts);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const syncedRetryFailure = synchronizeCoordinatorPlanState(root, mission, retry.plan);
|
|
601
|
+
console.error(chalk.red(`Coordinator retry execution failed: ${error.message}`));
|
|
602
|
+
if (opts.json) {
|
|
603
|
+
console.log(JSON.stringify({
|
|
604
|
+
dispatch_mode: 'coordinator',
|
|
605
|
+
retry: true,
|
|
606
|
+
mission_id: mission.mission_id,
|
|
607
|
+
plan_id: retry.plan.plan_id,
|
|
608
|
+
workstream_id: opts.workstream,
|
|
609
|
+
repo_id: retry.retryResult.repo_id,
|
|
610
|
+
retried_repo_turn_id: retry.retryResult.failed_turn_id,
|
|
611
|
+
repo_turn_id: retry.retryResult.reissued_turn_id,
|
|
612
|
+
workstream_status: syncedRetryFailure.ok
|
|
613
|
+
? syncedRetryFailure.plan.workstreams.find((ws) => ws.workstream_id === opts.workstream)?.launch_status || 'needs_attention'
|
|
614
|
+
: 'needs_attention',
|
|
615
|
+
launch_record: retry.launchRecord,
|
|
616
|
+
error: error.message,
|
|
617
|
+
}, null, 2));
|
|
618
|
+
}
|
|
619
|
+
process.exit(1);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const syncedRetry = synchronizeCoordinatorPlanState(root, mission, retry.plan);
|
|
623
|
+
const retriedPlan = syncedRetry.ok ? syncedRetry.plan : retry.plan;
|
|
624
|
+
const retriedWorkstream = retriedPlan.workstreams.find((ws) => ws.workstream_id === opts.workstream);
|
|
625
|
+
const retriedLaunchRecord = retriedPlan.launch_records.find(
|
|
626
|
+
(record) => record.workstream_id === opts.workstream && record.dispatch_mode === 'coordinator',
|
|
627
|
+
) || retry.launchRecord;
|
|
628
|
+
|
|
629
|
+
if (opts.json) {
|
|
630
|
+
console.log(JSON.stringify({
|
|
631
|
+
dispatch_mode: 'coordinator',
|
|
632
|
+
retry: true,
|
|
633
|
+
mission_id: mission.mission_id,
|
|
634
|
+
plan_id: retriedPlan.plan_id,
|
|
635
|
+
workstream_id: opts.workstream,
|
|
636
|
+
super_run_id: mission.coordinator.super_run_id,
|
|
637
|
+
repo_id: retry.retryResult.repo_id,
|
|
638
|
+
retried_repo_turn_id: retry.retryResult.failed_turn_id,
|
|
639
|
+
repo_turn_id: retry.retryResult.reissued_turn_id,
|
|
640
|
+
role: retry.retryResult.role,
|
|
641
|
+
bundle_path: retry.retryResult.bundle_path,
|
|
642
|
+
context_ref: retry.retryResult.context_ref,
|
|
643
|
+
workstream_status: retriedWorkstream?.launch_status || 'launched',
|
|
644
|
+
launch_record: retriedLaunchRecord,
|
|
645
|
+
exit_code: execution?.exitCode ?? 0,
|
|
646
|
+
}, null, 2));
|
|
647
|
+
if ((execution?.exitCode ?? 0) !== 0) {
|
|
648
|
+
process.exit(execution.exitCode);
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
console.log(chalk.green(`Retried coordinator workstream ${chalk.bold(opts.workstream)} in ${chalk.bold(retry.retryResult.repo_id)}`));
|
|
654
|
+
console.log('');
|
|
655
|
+
console.log(chalk.dim(` Mission: ${mission.mission_id}`));
|
|
656
|
+
console.log(chalk.dim(` Plan: ${retriedPlan.plan_id}`));
|
|
657
|
+
console.log(chalk.dim(` Super Run: ${mission.coordinator.super_run_id}`));
|
|
658
|
+
console.log(chalk.dim(` Repo: ${retry.retryResult.repo_id}`));
|
|
659
|
+
console.log(chalk.dim(` Old Turn: ${retry.retryResult.failed_turn_id}`));
|
|
660
|
+
console.log(chalk.dim(` New Turn: ${retry.retryResult.reissued_turn_id}`));
|
|
661
|
+
console.log(chalk.dim(` Workstream: ${retriedWorkstream?.launch_status || 'launched'}`));
|
|
662
|
+
console.log('');
|
|
663
|
+
renderPlan(retriedPlan);
|
|
664
|
+
if ((execution?.exitCode ?? 0) !== 0) {
|
|
665
|
+
console.error(chalk.red(`Coordinator retry execution ended with exit code ${execution.exitCode}.`));
|
|
666
|
+
process.exit(execution.exitCode);
|
|
667
|
+
}
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
568
671
|
const assignment = selectAssignmentForWorkstream(
|
|
569
672
|
mission.coordinator.workspace_path,
|
|
570
673
|
coordinatorState,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
buildProviderHeaders,
|
|
5
3
|
buildProviderRequest,
|
|
6
4
|
PROVIDER_ENDPOINTS,
|
|
7
5
|
} from './adapters/api-proxy-adapter.js';
|
|
6
|
+
import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
|
|
8
7
|
|
|
9
8
|
const PROBEABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
|
|
10
9
|
const DEFAULT_TIMEOUT_MS = 8_000;
|
|
@@ -35,8 +34,8 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
|
|
|
35
34
|
* Maps binary name to expected transport.
|
|
36
35
|
*/
|
|
37
36
|
const KNOWN_CLI_TRANSPORTS = {
|
|
38
|
-
claude: 'stdin',
|
|
39
|
-
codex: 'argv',
|
|
37
|
+
claude: ['stdin'],
|
|
38
|
+
codex: ['argv', 'stdin'],
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
function formatCommand(command, args = []) {
|
|
@@ -78,11 +77,6 @@ function commandHead(runtime) {
|
|
|
78
77
|
return null;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
function resolveBinary(command) {
|
|
82
|
-
const resolver = process.platform === 'win32' ? 'where' : 'which';
|
|
83
|
-
execFileSync(resolver, [command], { stdio: 'ignore' });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
80
|
function resolveProviderEndpoint(runtime) {
|
|
87
81
|
if (typeof runtime?.base_url === 'string' && runtime.base_url.trim()) {
|
|
88
82
|
return runtime.base_url.trim();
|
|
@@ -153,7 +147,7 @@ async function probeHttp({ url, method = 'GET', headers = {}, body, timeoutMs })
|
|
|
153
147
|
}
|
|
154
148
|
}
|
|
155
149
|
|
|
156
|
-
async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
|
|
150
|
+
async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {}) {
|
|
157
151
|
const head = commandHead(runtime);
|
|
158
152
|
const base = {
|
|
159
153
|
runtime_id: runtimeId,
|
|
@@ -170,22 +164,22 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel) {
|
|
|
170
164
|
};
|
|
171
165
|
}
|
|
172
166
|
|
|
173
|
-
|
|
174
|
-
|
|
167
|
+
const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
|
|
168
|
+
if (spawnProbe.ok) {
|
|
175
169
|
return {
|
|
176
170
|
...base,
|
|
177
171
|
level: 'pass',
|
|
178
|
-
command: head,
|
|
179
|
-
detail:
|
|
180
|
-
};
|
|
181
|
-
} catch {
|
|
182
|
-
return {
|
|
183
|
-
...base,
|
|
184
|
-
level: 'fail',
|
|
185
|
-
command: head,
|
|
186
|
-
detail: `${head} was not found on PATH`,
|
|
172
|
+
command: spawnProbe.command || head,
|
|
173
|
+
detail: spawnProbe.detail,
|
|
187
174
|
};
|
|
188
175
|
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...base,
|
|
179
|
+
level: 'fail',
|
|
180
|
+
command: spawnProbe.command || head,
|
|
181
|
+
detail: spawnProbe.detail,
|
|
182
|
+
};
|
|
189
183
|
}
|
|
190
184
|
|
|
191
185
|
async function probeApiProxy(runtimeId, runtime, timeoutMs) {
|
|
@@ -331,6 +325,27 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
|
|
|
331
325
|
if (boundRoles.length === 0) return { warnings };
|
|
332
326
|
|
|
333
327
|
const authoritativeRoles = boundRoles.filter((r) => r.write_authority === 'authoritative');
|
|
328
|
+
const isCodex = binaryName === 'codex' || binaryName.endsWith('/codex');
|
|
329
|
+
|
|
330
|
+
if (isCodex) {
|
|
331
|
+
if (commandTokens[1] !== 'exec') {
|
|
332
|
+
warnings.push({
|
|
333
|
+
probe_kind: 'command_intent',
|
|
334
|
+
level: 'warn',
|
|
335
|
+
detail: 'OpenAI Codex CLI governed local runs should use the non-interactive "exec" subcommand. Top-level "codex" is the interactive entrypoint.',
|
|
336
|
+
fix: 'Use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (commandTokens.includes('--quiet')) {
|
|
341
|
+
warnings.push({
|
|
342
|
+
probe_kind: 'command_intent',
|
|
343
|
+
level: 'warn',
|
|
344
|
+
detail: 'OpenAI Codex CLI rejects "--quiet" in governed local_cli commands on the current CLI. The command exits before the turn starts.',
|
|
345
|
+
fix: 'Remove "--quiet" and use ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "{prompt}"]',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
334
349
|
|
|
335
350
|
// Check known CLI authority flags
|
|
336
351
|
const knownCli = KNOWN_CLI_AUTHORITY_FLAGS.find((entry) => binaryName === entry.binary || binaryName.endsWith(`/${entry.binary}`));
|
|
@@ -359,7 +374,7 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
|
|
|
359
374
|
|
|
360
375
|
// Prompt transport validation
|
|
361
376
|
const transport = runtime.prompt_transport || 'dispatch_bundle_only';
|
|
362
|
-
const
|
|
377
|
+
const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
|
|
363
378
|
|
|
364
379
|
if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
|
|
365
380
|
warnings.push({
|
|
@@ -370,13 +385,13 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
|
|
|
370
385
|
});
|
|
371
386
|
}
|
|
372
387
|
|
|
373
|
-
if (
|
|
388
|
+
if (knownTransports && !knownTransports.includes(transport) && transport !== 'dispatch_bundle_only') {
|
|
374
389
|
const transportLabel = knownCli ? knownCli.label : binaryName;
|
|
375
390
|
warnings.push({
|
|
376
391
|
probe_kind: 'transport_intent',
|
|
377
392
|
level: 'warn',
|
|
378
|
-
detail: `${transportLabel} typically uses "${
|
|
379
|
-
fix: `Set prompt_transport to "${
|
|
393
|
+
detail: `${transportLabel} typically uses ${knownTransports.map((value) => `"${value}"`).join(' or ')} transport, but this runtime is configured with "${transport}"`,
|
|
394
|
+
fix: `Set prompt_transport to ${knownTransports.map((value) => `"${value}"`).join(' or ')} or "dispatch_bundle_only"`,
|
|
380
395
|
});
|
|
381
396
|
}
|
|
382
397
|
|
|
@@ -414,7 +429,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
|
|
|
414
429
|
}
|
|
415
430
|
|
|
416
431
|
if (runtime.type === 'local_cli') {
|
|
417
|
-
const result = await probeLocalCommand(runtimeId, runtime, 'command_presence');
|
|
432
|
+
const result = await probeLocalCommand(runtimeId, runtime, 'command_presence', options);
|
|
418
433
|
// Add authority-intent and transport analysis when roles are available
|
|
419
434
|
if (roles) {
|
|
420
435
|
const { warnings } = analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles);
|
|
@@ -437,7 +452,7 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
|
|
|
437
452
|
if (runtime.transport === 'streamable_http') {
|
|
438
453
|
return probeHttpRuntime(runtimeId, runtime, timeoutMs);
|
|
439
454
|
}
|
|
440
|
-
return probeLocalCommand(runtimeId, runtime, 'command_presence');
|
|
455
|
+
return probeLocalCommand(runtimeId, runtime, 'command_presence', options);
|
|
441
456
|
}
|
|
442
457
|
|
|
443
458
|
return probeHttpRuntime(runtimeId, runtime, timeoutMs);
|
|
@@ -22,6 +22,7 @@ import { dispatchMcp } from './adapters/mcp-adapter.js';
|
|
|
22
22
|
import { dispatchRemoteAgent } from './adapters/remote-agent-adapter.js';
|
|
23
23
|
import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js';
|
|
24
24
|
import { validateStagedTurnResult } from './turn-result-validator.js';
|
|
25
|
+
import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
|
|
25
26
|
|
|
26
27
|
const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
|
|
27
28
|
const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
|
|
@@ -130,6 +131,26 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
|
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
if (runtime.type === 'local_cli' || (runtime.type === 'mcp' && (runtime.transport || 'stdio') !== 'streamable_http')) {
|
|
135
|
+
const spawnProbe = probeRuntimeSpawnContext(scratchRoot, scratchContext.config.runtimes[runtimeId], { runtimeId });
|
|
136
|
+
if (!spawnProbe.ok) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
exitCode: 1,
|
|
140
|
+
overall: 'fail',
|
|
141
|
+
runtime_id: runtimeId,
|
|
142
|
+
runtime_type: runtime.type,
|
|
143
|
+
role_id: roleSelection.roleId,
|
|
144
|
+
timeout_ms: timeoutMs,
|
|
145
|
+
warnings,
|
|
146
|
+
dispatch: null,
|
|
147
|
+
validation: null,
|
|
148
|
+
error: spawnProbe.detail,
|
|
149
|
+
scratch_root: scratchRoot,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
133
154
|
const initResult = initializeGovernedRun(scratchRoot, scratchContext.config, {
|
|
134
155
|
provenance: {
|
|
135
156
|
trigger: 'connector_validate',
|
|
@@ -85,6 +85,17 @@ function buildPlanSummary(plan) {
|
|
|
85
85
|
completed_at: lr.completed_at || null,
|
|
86
86
|
status: lr.status,
|
|
87
87
|
terminal_reason: lr.terminal_reason || null,
|
|
88
|
+
dispatch_mode: lr.dispatch_mode || null,
|
|
89
|
+
...(lr.dispatch_mode === 'coordinator' && Array.isArray(lr.repo_dispatches) ? {
|
|
90
|
+
repo_dispatches: lr.repo_dispatches.map((rd) => ({
|
|
91
|
+
repo_id: rd.repo_id,
|
|
92
|
+
repo_turn_id: rd.repo_turn_id,
|
|
93
|
+
role: rd.role,
|
|
94
|
+
dispatched_at: rd.dispatched_at,
|
|
95
|
+
...(rd.is_retry ? { is_retry: true, retry_of: rd.retry_of } : {}),
|
|
96
|
+
...(rd.retried_at ? { retried_at: rd.retried_at, retry_reason: rd.retry_reason } : {}),
|
|
97
|
+
})),
|
|
98
|
+
} : {}),
|
|
88
99
|
})),
|
|
89
100
|
};
|
|
90
101
|
}
|
|
@@ -2488,7 +2488,8 @@ export function reissueTurn(root, config, opts = {}) {
|
|
|
2488
2488
|
const oldRuntimeId = oldTurn.runtime_id;
|
|
2489
2489
|
|
|
2490
2490
|
// Resolve current runtime binding (may have changed in config)
|
|
2491
|
-
|
|
2491
|
+
// BUG-25 fix: normalized config uses runtime_id, raw config uses runtime
|
|
2492
|
+
const currentRuntimeId = role.runtime_id || role.runtime;
|
|
2492
2493
|
const currentRuntime = config.runtimes?.[currentRuntimeId];
|
|
2493
2494
|
if (!currentRuntime) {
|
|
2494
2495
|
return { ok: false, error: `Runtime "${currentRuntimeId}" not found in config for role "${roleId}"` };
|
package/src/lib/mission-plans.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* Plans are NOT protocol-normative.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
10
10
|
import { randomUUID } from 'crypto';
|
|
11
11
|
import { join } from 'path';
|
|
12
12
|
import { loadChainReport } from './chain-reports.js';
|
|
13
|
-
import {
|
|
13
|
+
import { writeDispatchBundle } from './dispatch-bundle.js';
|
|
14
|
+
import { loadProjectContext, loadProjectState } from './config.js';
|
|
15
|
+
import { reissueTurn } from './governed-state.js';
|
|
16
|
+
import { emitRunEvent } from './run-events.js';
|
|
17
|
+
import { readBarriers, readCoordinatorHistory, recordCoordinatorDecision } from './coordinator-state.js';
|
|
14
18
|
import { loadCoordinatorConfig } from './coordinator-config.js';
|
|
15
19
|
|
|
16
20
|
// ── Plan artifact directory ──────────────────────────────────────────────────
|
|
@@ -486,6 +490,7 @@ function isAcceptedRepoHistoryEntry(entry) {
|
|
|
486
490
|
}
|
|
487
491
|
|
|
488
492
|
const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'rejected', 'retrying', 'conflicted']);
|
|
493
|
+
const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance']);
|
|
489
494
|
|
|
490
495
|
function getLatestRepoDispatches(launchRecord) {
|
|
491
496
|
const latestByRepo = new Map();
|
|
@@ -554,6 +559,76 @@ function buildCoordinatorRepoFailures(coordinatorConfig, launchRecord) {
|
|
|
554
559
|
return failures;
|
|
555
560
|
}
|
|
556
561
|
|
|
562
|
+
function appendCoordinatorHistoryEntry(workspacePath, entry) {
|
|
563
|
+
const historyPath = join(workspacePath, '.agentxchain', 'multirepo', 'history.jsonl');
|
|
564
|
+
mkdirSync(join(workspacePath, '.agentxchain', 'multirepo'), { recursive: true });
|
|
565
|
+
appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function findDependentDispatchAfter(plan, workstreamId, timestamp) {
|
|
569
|
+
const since = new Date(timestamp || 0).getTime();
|
|
570
|
+
if (Number.isNaN(since)) return null;
|
|
571
|
+
|
|
572
|
+
for (const candidate of plan.workstreams || []) {
|
|
573
|
+
if (!Array.isArray(candidate.depends_on) || !candidate.depends_on.includes(workstreamId)) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
for (const record of plan.launch_records || []) {
|
|
578
|
+
if (record?.workstream_id !== candidate.workstream_id) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (record.dispatch_mode === 'coordinator') {
|
|
583
|
+
for (const dispatch of record.repo_dispatches || []) {
|
|
584
|
+
const dispatchedAt = new Date(dispatch?.dispatched_at || 0).getTime();
|
|
585
|
+
if (!Number.isNaN(dispatchedAt) && dispatchedAt > since) {
|
|
586
|
+
return {
|
|
587
|
+
workstream_id: candidate.workstream_id,
|
|
588
|
+
repo_id: dispatch.repo_id || null,
|
|
589
|
+
dispatched_at: dispatch.dispatched_at || null,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const launchedAt = new Date(record.launched_at || 0).getTime();
|
|
597
|
+
if (!Number.isNaN(launchedAt) && launchedAt > since) {
|
|
598
|
+
return {
|
|
599
|
+
workstream_id: candidate.workstream_id,
|
|
600
|
+
repo_id: null,
|
|
601
|
+
dispatched_at: record.launched_at || null,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function loadRepoTurnForRetry(repoPath, repoTurnId) {
|
|
611
|
+
const context = loadProjectContext(repoPath);
|
|
612
|
+
if (!context) {
|
|
613
|
+
return { ok: false, error: `Repo at "${repoPath}" is not a loadable governed project.` };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const state = loadProjectState(context.root, context.config);
|
|
617
|
+
if (!state) {
|
|
618
|
+
return { ok: false, error: `Repo at "${repoPath}" has no governed state.` };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const activeTurn = state.active_turns?.[repoTurnId] || null;
|
|
622
|
+
if (!activeTurn) {
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
error: `Repo turn "${repoTurnId}" is no longer active. Use repo-local recovery instead of coordinator --retry.`,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return { ok: true, root: context.root, config: context.config, state, activeTurn };
|
|
630
|
+
}
|
|
631
|
+
|
|
557
632
|
function buildCoordinatorWorkstreamProgress(coordinatorConfig, history, barriers, workstreamId) {
|
|
558
633
|
const coordinatorWorkstream = coordinatorConfig?.workstreams?.[workstreamId];
|
|
559
634
|
if (!coordinatorWorkstream) {
|
|
@@ -904,6 +979,188 @@ export function launchCoordinatorWorkstream(root, mission, planId, workstreamId,
|
|
|
904
979
|
return { ok: true, plan: synced.ok ? synced.plan : plan, workstream: ws, launchRecord };
|
|
905
980
|
}
|
|
906
981
|
|
|
982
|
+
export function retryCoordinatorWorkstream(root, mission, planId, workstreamId, coordinatorConfig, options = {}) {
|
|
983
|
+
const plan = loadPlan(root, mission.mission_id, planId);
|
|
984
|
+
if (!plan) {
|
|
985
|
+
return { ok: false, error: `Plan not found: ${planId}` };
|
|
986
|
+
}
|
|
987
|
+
if (!mission?.coordinator?.workspace_path || !mission?.coordinator?.super_run_id) {
|
|
988
|
+
return { ok: false, error: 'Mission is not bound to a coordinator run.' };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const synced = synchronizeCoordinatorPlanState(root, mission, plan);
|
|
992
|
+
const workingPlan = synced.ok ? synced.plan : plan;
|
|
993
|
+
const ws = workingPlan.workstreams.find((candidate) => candidate.workstream_id === workstreamId);
|
|
994
|
+
if (!ws) {
|
|
995
|
+
return { ok: false, error: `Workstream not found: ${workstreamId}` };
|
|
996
|
+
}
|
|
997
|
+
if (ws.launch_status !== 'needs_attention') {
|
|
998
|
+
return {
|
|
999
|
+
ok: false,
|
|
1000
|
+
error: `Workstream ${workstreamId} is not in needs_attention state. Nothing to retry.`,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const launchRecord = getLatestCoordinatorLaunchRecord(workingPlan, workstreamId);
|
|
1005
|
+
if (!launchRecord) {
|
|
1006
|
+
return { ok: false, error: `No coordinator launch record found for workstream ${workstreamId}.` };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const latestDispatchesByRepo = new Map(
|
|
1010
|
+
getLatestRepoDispatches(launchRecord).map((dispatch) => [dispatch.repo_id, dispatch]),
|
|
1011
|
+
);
|
|
1012
|
+
const repoFailures = buildCoordinatorRepoFailures(coordinatorConfig, launchRecord);
|
|
1013
|
+
launchRecord.repo_failures = repoFailures;
|
|
1014
|
+
|
|
1015
|
+
if (repoFailures.length === 0) {
|
|
1016
|
+
return { ok: false, error: `Workstream ${workstreamId} has no retryable repo failures.` };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const retryableFailures = [];
|
|
1020
|
+
const blockedFailures = [];
|
|
1021
|
+
|
|
1022
|
+
for (const failure of repoFailures) {
|
|
1023
|
+
if (!RETRYABLE_COORDINATOR_FAILURE_STATUSES.has(failure.failure_status)) {
|
|
1024
|
+
blockedFailures.push(`${failure.repo_id} (${failure.failure_status})`);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
retryableFailures.push(failure);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (retryableFailures.length === 0) {
|
|
1031
|
+
return {
|
|
1032
|
+
ok: false,
|
|
1033
|
+
error: `No retryable repo failures in workstream ${workstreamId}. Manual intervention required: ${blockedFailures.join(', ')}`,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (retryableFailures.length > 1) {
|
|
1038
|
+
return {
|
|
1039
|
+
ok: false,
|
|
1040
|
+
error: `Workstream ${workstreamId} has multiple retryable repo failures. Recover them repo-locally one at a time before relaunching the coordinator workstream.`,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const failure = retryableFailures[0];
|
|
1045
|
+
const failedDispatch = latestDispatchesByRepo.get(failure.repo_id);
|
|
1046
|
+
if (!failedDispatch) {
|
|
1047
|
+
return { ok: false, error: `Missing launch metadata for failed repo ${failure.repo_id}.` };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const downstreamBlocker = findDependentDispatchAfter(workingPlan, workstreamId, failedDispatch.dispatched_at);
|
|
1051
|
+
if (downstreamBlocker) {
|
|
1052
|
+
return {
|
|
1053
|
+
ok: false,
|
|
1054
|
+
error: `Cannot retry workstream ${workstreamId}: dependent workstream "${downstreamBlocker.workstream_id}" has already dispatched since the failed repo turn.`,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const repoPath = coordinatorConfig?.repos?.[failure.repo_id]?.resolved_path;
|
|
1059
|
+
if (!repoPath) {
|
|
1060
|
+
return { ok: false, error: `Coordinator config has no resolved_path for repo "${failure.repo_id}".` };
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const repoTurn = loadRepoTurnForRetry(repoPath, failure.repo_turn_id);
|
|
1064
|
+
if (!repoTurn.ok) {
|
|
1065
|
+
return repoTurn;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const reason = options.reason || `coordinator retry: ${workstreamId}/${failure.repo_id}`;
|
|
1069
|
+
const reissued = reissueTurn(repoTurn.root, repoTurn.config, {
|
|
1070
|
+
turnId: failure.repo_turn_id,
|
|
1071
|
+
reason,
|
|
1072
|
+
});
|
|
1073
|
+
if (!reissued.ok) {
|
|
1074
|
+
return { ok: false, error: reissued.error };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const bundleResult = writeDispatchBundle(repoTurn.root, reissued.state, repoTurn.config, {
|
|
1078
|
+
turnId: reissued.newTurn.turn_id,
|
|
1079
|
+
});
|
|
1080
|
+
if (!bundleResult.ok) {
|
|
1081
|
+
return { ok: false, error: `Turn reissued but dispatch bundle failed: ${bundleResult.error}` };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const now = new Date().toISOString();
|
|
1085
|
+
failedDispatch.retried_at = now;
|
|
1086
|
+
failedDispatch.retry_reason = failure.failure_status;
|
|
1087
|
+
launchRecord.repo_dispatches.push({
|
|
1088
|
+
repo_id: failure.repo_id,
|
|
1089
|
+
repo_turn_id: reissued.newTurn.turn_id,
|
|
1090
|
+
role: reissued.newTurn.assigned_role,
|
|
1091
|
+
dispatched_at: now,
|
|
1092
|
+
bundle_path: bundleResult.bundlePath,
|
|
1093
|
+
context_ref: failedDispatch.context_ref || null,
|
|
1094
|
+
is_retry: true,
|
|
1095
|
+
retry_of: failure.repo_turn_id,
|
|
1096
|
+
});
|
|
1097
|
+
launchRecord.status = 'launched';
|
|
1098
|
+
launchRecord.repo_failures = [];
|
|
1099
|
+
ws.launch_status = 'launched';
|
|
1100
|
+
|
|
1101
|
+
const otherNeedsAttention = (workingPlan.workstreams || []).some(
|
|
1102
|
+
(candidate) => candidate.workstream_id !== workstreamId && candidate.launch_status === 'needs_attention',
|
|
1103
|
+
);
|
|
1104
|
+
workingPlan.status = otherNeedsAttention ? 'needs_attention' : 'approved';
|
|
1105
|
+
workingPlan.updated_at = now;
|
|
1106
|
+
writePlanArtifact(root, mission.mission_id, workingPlan);
|
|
1107
|
+
|
|
1108
|
+
appendCoordinatorHistoryEntry(mission.coordinator.workspace_path, {
|
|
1109
|
+
type: 'coordinator_retry',
|
|
1110
|
+
timestamp: now,
|
|
1111
|
+
super_run_id: mission.coordinator.super_run_id,
|
|
1112
|
+
workstream_id: workstreamId,
|
|
1113
|
+
repo_id: failure.repo_id,
|
|
1114
|
+
failed_turn_id: failure.repo_turn_id,
|
|
1115
|
+
reissued_turn_id: reissued.newTurn.turn_id,
|
|
1116
|
+
retry_reason: failure.failure_status,
|
|
1117
|
+
});
|
|
1118
|
+
recordCoordinatorDecision(mission.coordinator.workspace_path, {
|
|
1119
|
+
super_run_id: mission.coordinator.super_run_id,
|
|
1120
|
+
phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
|
|
1121
|
+
}, {
|
|
1122
|
+
category: 'retry',
|
|
1123
|
+
statement: `Retried ${failure.repo_id} for workstream ${workstreamId}`,
|
|
1124
|
+
repo_id: failure.repo_id,
|
|
1125
|
+
repo_turn_id: reissued.newTurn.turn_id,
|
|
1126
|
+
workstream_id: workstreamId,
|
|
1127
|
+
reason: failure.failure_status,
|
|
1128
|
+
context_ref: failedDispatch.context_ref || null,
|
|
1129
|
+
});
|
|
1130
|
+
emitRunEvent(mission.coordinator.workspace_path, 'coordinator_retry', {
|
|
1131
|
+
run_id: mission.coordinator.super_run_id,
|
|
1132
|
+
phase: coordinatorConfig?.workstreams?.[workstreamId]?.phase || null,
|
|
1133
|
+
status: 'active',
|
|
1134
|
+
turn: { turn_id: reissued.newTurn.turn_id, role_id: reissued.newTurn.assigned_role },
|
|
1135
|
+
payload: {
|
|
1136
|
+
workstream_id: workstreamId,
|
|
1137
|
+
repo_id: failure.repo_id,
|
|
1138
|
+
failed_turn_id: failure.repo_turn_id,
|
|
1139
|
+
reissued_turn_id: reissued.newTurn.turn_id,
|
|
1140
|
+
retry_reason: failure.failure_status,
|
|
1141
|
+
retry_count: launchRecord.repo_dispatches.filter((dispatch) => dispatch.repo_id === failure.repo_id && dispatch.is_retry).length,
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
const afterRetry = synchronizeCoordinatorPlanState(root, mission, workingPlan);
|
|
1146
|
+
return {
|
|
1147
|
+
ok: true,
|
|
1148
|
+
plan: afterRetry.ok ? afterRetry.plan : workingPlan,
|
|
1149
|
+
workstream: ws,
|
|
1150
|
+
launchRecord,
|
|
1151
|
+
retryResult: {
|
|
1152
|
+
repo_id: failure.repo_id,
|
|
1153
|
+
failed_turn_id: failure.repo_turn_id,
|
|
1154
|
+
reissued_turn_id: reissued.newTurn.turn_id,
|
|
1155
|
+
role: reissued.newTurn.assigned_role,
|
|
1156
|
+
repo_path: repoTurn.root,
|
|
1157
|
+
bundle_path: bundleResult.bundlePath,
|
|
1158
|
+
context_ref: failedDispatch.context_ref || null,
|
|
1159
|
+
retry_reason: failure.failure_status,
|
|
1160
|
+
},
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
907
1164
|
/**
|
|
908
1165
|
* Record the outcome of a launched workstream after its chain completes.
|
|
909
1166
|
*
|
|
@@ -311,9 +311,23 @@ export function validateReleaseAlignment(repoRoot, { targetVersion, scope = RELE
|
|
|
311
311
|
const context = getReleaseAlignmentContext(repoRoot, { targetVersion });
|
|
312
312
|
const surfaces = RELEASE_ALIGNMENT_SURFACES.filter((surface) => surface.scopes.includes(scope));
|
|
313
313
|
const errors = [];
|
|
314
|
+
const surfaceResults = [];
|
|
314
315
|
|
|
315
316
|
for (const surface of surfaces) {
|
|
316
|
-
|
|
317
|
+
let surfaceErrors = [];
|
|
318
|
+
try {
|
|
319
|
+
surfaceErrors = surface.check(context, repoRoot) || [];
|
|
320
|
+
} catch (error) {
|
|
321
|
+
surfaceErrors = [
|
|
322
|
+
error instanceof Error ? error.message : String(error),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
surfaceResults.push({
|
|
326
|
+
surface_id: surface.id,
|
|
327
|
+
label: surface.label,
|
|
328
|
+
ok: surfaceErrors.length === 0,
|
|
329
|
+
errors: surfaceErrors,
|
|
330
|
+
});
|
|
317
331
|
for (const error of surfaceErrors) {
|
|
318
332
|
errors.push({
|
|
319
333
|
surface_id: surface.id,
|
|
@@ -331,6 +345,12 @@ export function validateReleaseAlignment(repoRoot, { targetVersion, scope = RELE
|
|
|
331
345
|
aggregateEvidenceLine: context.aggregateEvidenceLine,
|
|
332
346
|
checkedSurfaceCount: surfaces.length,
|
|
333
347
|
checkedSurfaceIds: surfaces.map((surface) => surface.id),
|
|
348
|
+
checkedSurfaces: surfaces.map((surface) => ({
|
|
349
|
+
id: surface.id,
|
|
350
|
+
label: surface.label,
|
|
351
|
+
scopes: [...surface.scopes],
|
|
352
|
+
})),
|
|
353
|
+
surfaceResults,
|
|
334
354
|
errors,
|
|
335
355
|
};
|
|
336
356
|
}
|
package/src/lib/run-events.js
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 500;
|
|
6
|
+
const PROMPT_PLACEHOLDER = 'AgentXchain spawn-context probe';
|
|
7
|
+
|
|
8
|
+
function resolveLocalCliPromptTransport(runtime) {
|
|
9
|
+
const valid = new Set(['argv', 'stdin', 'dispatch_bundle_only']);
|
|
10
|
+
if (valid.has(runtime?.prompt_transport)) {
|
|
11
|
+
return runtime.prompt_transport;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parts = Array.isArray(runtime?.command)
|
|
15
|
+
? runtime.command
|
|
16
|
+
: [runtime?.command, ...(Array.isArray(runtime?.args) ? runtime.args : [])];
|
|
17
|
+
const hasPrompt = parts.some((part) => typeof part === 'string' && part.includes('{prompt}'));
|
|
18
|
+
return hasPrompt ? 'argv' : 'dispatch_bundle_only';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveLocalCliInvocation(runtime) {
|
|
22
|
+
if (!runtime?.command) {
|
|
23
|
+
return { command: null, args: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const transport = resolveLocalCliPromptTransport(runtime);
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(runtime.command)) {
|
|
29
|
+
const first = runtime.command[0] || '';
|
|
30
|
+
const headParts = typeof first === 'string' && first.includes(' ') ? first.split(/\s+/) : [first];
|
|
31
|
+
const [command, ...headArgs] = headParts;
|
|
32
|
+
const rest = [...headArgs, ...runtime.command.slice(1)];
|
|
33
|
+
const args = transport === 'argv'
|
|
34
|
+
? rest.map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
|
|
35
|
+
: rest.filter((arg) => arg !== '{prompt}');
|
|
36
|
+
return { command, args };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const args = transport === 'argv'
|
|
40
|
+
? (runtime.args || []).map((arg) => arg === '{prompt}' ? PROMPT_PLACEHOLDER : arg)
|
|
41
|
+
: (runtime.args || []).filter((arg) => arg !== '{prompt}');
|
|
42
|
+
return { command: runtime.command, args };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveMcpInvocation(runtime) {
|
|
46
|
+
if (!runtime?.command) {
|
|
47
|
+
return { command: null, args: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(runtime.command)) {
|
|
51
|
+
const [command, ...args] = runtime.command;
|
|
52
|
+
return { command, args };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
command: runtime.command,
|
|
57
|
+
args: Array.isArray(runtime.args) ? runtime.args : [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveInvocation(runtime) {
|
|
62
|
+
if (runtime?.type === 'local_cli') {
|
|
63
|
+
return resolveLocalCliInvocation(runtime);
|
|
64
|
+
}
|
|
65
|
+
return resolveMcpInvocation(runtime);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildResolutionFix(command) {
|
|
69
|
+
const commandValue = String(command || '');
|
|
70
|
+
const commandBase = basename(commandValue);
|
|
71
|
+
|
|
72
|
+
if (commandBase === 'codex' || commandBase === 'codex.exe') {
|
|
73
|
+
return 'Set "command" to the absolute path, e.g. "/Applications/Codex.app/Contents/Resources/codex", or add Codex to PATH in the dispatch spawn context.';
|
|
74
|
+
}
|
|
75
|
+
if (commandValue.includes('~')) {
|
|
76
|
+
return 'Expand "~" to an absolute path in "command". Shell expansion does not apply to governed dispatch.';
|
|
77
|
+
}
|
|
78
|
+
return 'Set "command" to an absolute path or add it to PATH in the dispatch spawn context.';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function probeRuntimeSpawnContext(root, runtime, options = {}) {
|
|
82
|
+
const runtimeId = options.runtimeId || null;
|
|
83
|
+
const cwd = runtime?.cwd ? join(root, runtime.cwd) : root;
|
|
84
|
+
const { command, args } = resolveInvocation(runtime);
|
|
85
|
+
|
|
86
|
+
if (!command) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
runtime_id: runtimeId,
|
|
90
|
+
command: null,
|
|
91
|
+
cwd,
|
|
92
|
+
detail: 'No command configured for the dispatch spawn context.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!existsSync(cwd)) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
runtime_id: runtimeId,
|
|
100
|
+
command,
|
|
101
|
+
cwd,
|
|
102
|
+
detail: `Runtime cwd "${runtime.cwd}" does not exist in the dispatch spawn context.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const probe = spawnSync(command, args, {
|
|
107
|
+
cwd,
|
|
108
|
+
env: { ...process.env, AGENTXCHAIN_SPAWN_PROBE: '1' },
|
|
109
|
+
stdio: 'ignore',
|
|
110
|
+
timeout: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
111
|
+
windowsHide: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (probe.error) {
|
|
115
|
+
const errorCode = probe.error.code || 'spawn_error';
|
|
116
|
+
if (errorCode === 'ETIMEDOUT') {
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
runtime_id: runtimeId,
|
|
120
|
+
command,
|
|
121
|
+
cwd,
|
|
122
|
+
timed_out: true,
|
|
123
|
+
detail: `"${command}" launched in the dispatch spawn context but exceeded the short probe timeout. Treating this as resolvable.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (errorCode === 'ENOENT') {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
runtime_id: runtimeId,
|
|
130
|
+
command,
|
|
131
|
+
cwd,
|
|
132
|
+
error_code: errorCode,
|
|
133
|
+
detail: `"${command}" is not resolvable in the dispatch spawn context. ${buildResolutionFix(command)}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (errorCode === 'EACCES') {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
runtime_id: runtimeId,
|
|
140
|
+
command,
|
|
141
|
+
cwd,
|
|
142
|
+
error_code: errorCode,
|
|
143
|
+
detail: `"${command}" exists but is not executable in the dispatch spawn context. Mark it executable or point "command" at the real executable path.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
runtime_id: runtimeId,
|
|
149
|
+
command,
|
|
150
|
+
cwd,
|
|
151
|
+
error_code: errorCode,
|
|
152
|
+
detail: `Dispatch spawn probe failed for "${command}": ${probe.error.message}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
runtime_id: runtimeId,
|
|
159
|
+
command,
|
|
160
|
+
cwd,
|
|
161
|
+
detail: `"${command}" is resolvable in the dispatch spawn context.`,
|
|
162
|
+
};
|
|
163
|
+
}
|