agentxchain 2.155.72 → 2.156.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/README.md +4 -8
- package/bin/agentxchain.js +22 -0
- package/dashboard/app.js +54 -0
- package/dashboard/components/org-audit-trail.js +161 -0
- package/dashboard/components/org-history.js +140 -0
- package/dashboard/components/org-overview.js +145 -0
- package/dashboard/components/org-runs.js +168 -0
- package/dashboard/index.html +4 -0
- package/package.json +4 -5
- package/scripts/migrate-node-test-to-vitest.mjs +98 -0
- package/scripts/release-postflight.sh +1 -1
- package/scripts/release-preflight.sh +5 -5
- package/scripts/verify-post-publish.sh +1 -1
- package/src/commands/ci-report.js +80 -0
- package/src/commands/doctor.js +22 -1
- package/src/commands/intake-approve.js +1 -0
- package/src/commands/replay.js +1 -0
- package/src/commands/run.js +50 -0
- package/src/commands/serve.js +64 -0
- package/src/commands/step.js +63 -1
- package/src/commands/verify.js +1 -0
- package/src/lib/adapters/local-cli-adapter.js +326 -2
- package/src/lib/api/execution-worker.js +192 -0
- package/src/lib/api/hosted-runner.js +494 -0
- package/src/lib/api/job-queue.js +152 -0
- package/src/lib/api/org-state-aggregator.js +428 -0
- package/src/lib/api/project-registry.js +148 -0
- package/src/lib/api/protocol-bridge.js +476 -0
- package/src/lib/approval-policy.js +12 -0
- package/src/lib/ci-reporter.js +188 -0
- package/src/lib/claude-local-auth.js +89 -1
- package/src/lib/connector-probe.js +21 -0
- package/src/lib/continuous-run.js +51 -3
- package/src/lib/dashboard/bridge-server.js +10 -5
- package/src/lib/dispatch-bundle.js +7 -3
- package/src/lib/dispatch-progress.js +9 -0
- package/src/lib/governed-state.js +5 -4
- package/src/lib/intake.js +32 -6
- package/src/lib/normalized-config.js +4 -0
- package/src/lib/recovery-classification.js +158 -0
- package/src/lib/report.js +91 -0
- package/src/lib/run-events.js +7 -1
- package/src/lib/schemas/agentxchain-config.schema.json +10 -0
- package/src/lib/scope-overlap.js +214 -0
- package/src/lib/turn-checkpoint.js +33 -3
- package/src/lib/turn-result-validator.js +47 -6
- package/src/lib/validation.js +11 -3
- package/src/lib/verification-replay.js +125 -4
- package/src/lib/vision-reader.js +16 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect CI environment from process.env.
|
|
5
|
+
* @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
|
|
6
|
+
*/
|
|
7
|
+
export function detectCIEnvironment() {
|
|
8
|
+
if (process.env.GITHUB_ACTIONS === 'true') {
|
|
9
|
+
return {
|
|
10
|
+
provider: 'github_actions',
|
|
11
|
+
run_url: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
|
|
12
|
+
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
13
|
+
: null,
|
|
14
|
+
run_id: process.env.GITHUB_RUN_ID || null,
|
|
15
|
+
ref: process.env.GITHUB_REF || null,
|
|
16
|
+
sha: process.env.GITHUB_SHA || null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (process.env.GITLAB_CI === 'true') {
|
|
20
|
+
return {
|
|
21
|
+
provider: 'gitlab_ci',
|
|
22
|
+
run_url: process.env.CI_PIPELINE_URL || null,
|
|
23
|
+
run_id: process.env.CI_PIPELINE_ID || null,
|
|
24
|
+
ref: process.env.CI_COMMIT_REF_NAME || null,
|
|
25
|
+
sha: process.env.CI_COMMIT_SHA || null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (process.env.CI === 'true') {
|
|
29
|
+
return {
|
|
30
|
+
provider: 'generic',
|
|
31
|
+
run_url: null,
|
|
32
|
+
run_id: null,
|
|
33
|
+
ref: null,
|
|
34
|
+
sha: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format governance report as GitHub Actions annotations.
|
|
42
|
+
* @param {{ overall: string, subject?: object }} report - Governance report from buildGovernanceReport()
|
|
43
|
+
* @returns {string} Newline-separated GitHub Actions workflow commands
|
|
44
|
+
*/
|
|
45
|
+
export function formatGitHubAnnotations(report) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
|
|
48
|
+
// Overall run status
|
|
49
|
+
if (report.overall === 'pass') {
|
|
50
|
+
lines.push(`::notice title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — PASS (${report.subject?.run?.phase || 'unknown'} phase)`);
|
|
51
|
+
} else if (report.overall === 'fail') {
|
|
52
|
+
lines.push(`::error title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — FAIL: ${report.message || 'governance check failed'}`);
|
|
53
|
+
} else {
|
|
54
|
+
lines.push(`::warning title=AgentXchain Governance::Run ${report.subject?.run?.run_id || 'unknown'} — ${report.overall}: ${report.message || 'review required'}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Gate annotations
|
|
58
|
+
const gates = report.subject?.run?.gate_summary;
|
|
59
|
+
if (gates && typeof gates === 'object') {
|
|
60
|
+
for (const [gateName, gateStatus] of Object.entries(gates)) {
|
|
61
|
+
if (typeof gateStatus !== 'string') continue;
|
|
62
|
+
const normalizedStatus = gateStatus.toLowerCase();
|
|
63
|
+
if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
|
|
64
|
+
lines.push(`::notice title=Gate ${gateName}::satisfied`);
|
|
65
|
+
} else {
|
|
66
|
+
lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Decision annotations (first 20 to avoid flooding)
|
|
72
|
+
const decisions = report.subject?.run?.decisions || [];
|
|
73
|
+
for (const d of decisions.slice(0, 20)) {
|
|
74
|
+
lines.push(`::notice title=Decision ${d.id || 'unknown'}::${d.statement || d.summary || 'no statement'}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Blocked-on annotation
|
|
78
|
+
if (report.subject?.run?.blocked_on) {
|
|
79
|
+
lines.push(`::error title=Blocked::${report.subject.run.blocked_reason || report.subject.run.blocked_on}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write governance report summary as GitHub Actions output variables.
|
|
87
|
+
* @param {{ overall: string, subject?: object }} report
|
|
88
|
+
* @param {string} outputPath - Path to $GITHUB_OUTPUT file
|
|
89
|
+
* @returns {string[]} Array of key=value pairs written
|
|
90
|
+
*/
|
|
91
|
+
export function writeGitHubOutputVars(report, outputPath) {
|
|
92
|
+
const run = report.subject?.run || {};
|
|
93
|
+
const pairs = [
|
|
94
|
+
`run_status=${report.overall || 'unknown'}`,
|
|
95
|
+
`run_id=${run.run_id || ''}`,
|
|
96
|
+
`phase=${run.phase || ''}`,
|
|
97
|
+
`blocked=${run.blocked_on ? 'true' : 'false'}`,
|
|
98
|
+
`turn_count=${(run.turns || []).length}`,
|
|
99
|
+
`decision_count=${(run.decisions || []).length}`,
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
if (outputPath) {
|
|
103
|
+
appendFileSync(outputPath, pairs.join('\n') + '\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return pairs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format governance report as JUnit XML.
|
|
111
|
+
* @param {{ overall: string, subject?: object }} report
|
|
112
|
+
* @returns {string} JUnit XML string
|
|
113
|
+
*/
|
|
114
|
+
export function formatJUnitXml(report) {
|
|
115
|
+
const run = report.subject?.run || {};
|
|
116
|
+
const gates = run.gate_summary || {};
|
|
117
|
+
const turns = run.turns || [];
|
|
118
|
+
|
|
119
|
+
// Escape XML special characters
|
|
120
|
+
const esc = (str) => String(str || '')
|
|
121
|
+
.replace(/&/g, '&')
|
|
122
|
+
.replace(/</g, '<')
|
|
123
|
+
.replace(/>/g, '>')
|
|
124
|
+
.replace(/"/g, '"');
|
|
125
|
+
|
|
126
|
+
// Build gate test cases
|
|
127
|
+
const gateEntries = Object.entries(gates).filter(([, v]) => typeof v === 'string');
|
|
128
|
+
const gateFailures = gateEntries.filter(([, status]) => {
|
|
129
|
+
const s = status.toLowerCase();
|
|
130
|
+
return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
|
|
131
|
+
});
|
|
132
|
+
const gateCases = gateEntries.map(([name, status]) => {
|
|
133
|
+
const s = status.toLowerCase();
|
|
134
|
+
const passed = s === 'satisfied' || s === 'pass' || s === 'passed';
|
|
135
|
+
if (passed) {
|
|
136
|
+
return ` <testcase name="${esc(name)}" classname="agentxchain.gates" time="0" />`;
|
|
137
|
+
}
|
|
138
|
+
return [
|
|
139
|
+
` <testcase name="${esc(name)}" classname="agentxchain.gates" time="0">`,
|
|
140
|
+
` <failure message="${esc(status)}">${esc(name)} gate status: ${esc(status)}</failure>`,
|
|
141
|
+
' </testcase>',
|
|
142
|
+
].join('\n');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Build turn test cases
|
|
146
|
+
const turnFailures = turns.filter((t) => t.status === 'failed' || t.status === 'blocked');
|
|
147
|
+
const turnCases = turns.map((t) => {
|
|
148
|
+
const name = `${t.turn_id || 'unknown'} (${t.role || 'unknown'})`;
|
|
149
|
+
const time = typeof t.duration_seconds === 'number' ? t.duration_seconds.toFixed(1) : '0';
|
|
150
|
+
if (t.status === 'completed' || t.status === 'accepted') {
|
|
151
|
+
return ` <testcase name="${esc(name)}" classname="agentxchain.turns" time="${time}" />`;
|
|
152
|
+
}
|
|
153
|
+
return [
|
|
154
|
+
` <testcase name="${esc(name)}" classname="agentxchain.turns" time="${time}">`,
|
|
155
|
+
` <failure message="${esc(t.status || 'unknown')}">${esc(t.summary || t.status || 'turn did not complete successfully')}</failure>`,
|
|
156
|
+
' </testcase>',
|
|
157
|
+
].join('\n');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const totalTests = gateEntries.length + turns.length;
|
|
161
|
+
const totalFailures = gateFailures.length + turnFailures.length;
|
|
162
|
+
const totalTime = typeof run.duration_seconds === 'number' ? run.duration_seconds.toFixed(1) : '0';
|
|
163
|
+
|
|
164
|
+
const xml = [
|
|
165
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
166
|
+
`<testsuites name="AgentXchain Governance" tests="${totalTests}" failures="${totalFailures}" errors="0" time="${totalTime}">`,
|
|
167
|
+
` <testsuite name="Gates" tests="${gateEntries.length}" failures="${gateFailures.length}" errors="0">`,
|
|
168
|
+
...gateCases,
|
|
169
|
+
' </testsuite>',
|
|
170
|
+
` <testsuite name="Turns" tests="${turns.length}" failures="${turnFailures.length}" errors="0">`,
|
|
171
|
+
...turnCases,
|
|
172
|
+
' </testsuite>',
|
|
173
|
+
'</testsuites>',
|
|
174
|
+
].join('\n');
|
|
175
|
+
|
|
176
|
+
return xml;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Derive CI-appropriate exit code from governance report.
|
|
181
|
+
* @param {{ overall: string }} report
|
|
182
|
+
* @returns {number} 0 = pass, 1 = fail, 2 = error
|
|
183
|
+
*/
|
|
184
|
+
export function deriveCIExitCode(report) {
|
|
185
|
+
if (report.overall === 'pass') return 0;
|
|
186
|
+
if (report.overall === 'fail') return 1;
|
|
187
|
+
return 2;
|
|
188
|
+
}
|
|
@@ -12,10 +12,23 @@ const CLAUDE_ENV_AUTH_KEYS = [
|
|
|
12
12
|
const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
|
|
13
13
|
const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
|
|
14
14
|
const CLAUDE_AUTH_FAILURE_RE = /authentication_failed|authentication_error|invalid authentication credentials|unauthorized|API Error:\s*401/i;
|
|
15
|
+
const CODEX_AUTH_FAILURE_RE = /unauthorized|invalid api key|invalid_api_key|authentication failed|authentication_failed|openai[\s\S]{0,200}401|api[_ -]?key[\s\S]{0,200}invalid|401[\s\S]{0,200}(openai|invalid|unauthorized)/i;
|
|
15
16
|
const CLAUDE_NODE_INCOMPATIBILITY_RE =
|
|
16
17
|
/TypeError:\s*Object not disposable|TypeError\(["']Object not disposable["']\)|Object not disposable[\s\S]{0,2000}Node\.js v(?:1[0-9]|20\.[0-4]\.)/i;
|
|
17
18
|
const CLAUDE_COMPATIBLE_NODE_MIN = { major: 20, minor: 5, patch: 0 };
|
|
18
19
|
|
|
20
|
+
// Rate-limit detection — provider-agnostic patterns for subprocess output.
|
|
21
|
+
// Matches Claude/Anthropic, OpenAI/Codex, and generic rate-limit signatures.
|
|
22
|
+
const RATE_LIMIT_RE = /rate_limit_error|"error"\s*:\s*"rate_limit"|rate[_\s-]?limited|hit[_\s-]your[_\s-]limit|usage\s+limit|rate\s+limit\s+reached|429\s*too\s*many\s*requests|too\s+many\s+requests/i;
|
|
23
|
+
|
|
24
|
+
// Reset time extraction — best-effort parsing of provider-reported reset delay.
|
|
25
|
+
const RESET_PATTERNS = [
|
|
26
|
+
/resets?\s+in\s+(\d+)\s*s/i, // "Resets in 42s"
|
|
27
|
+
/retry[_\s-]?after\s*[:=]\s*(\d+)/i, // "Retry-After: 30"
|
|
28
|
+
/wait\s+(\d+)\s*s(?:econds?)?/i, // "Please wait 60 seconds"
|
|
29
|
+
/(?:rate[_\s-]?limit|too\s+many)[\s\S]{0,80}?(\d+)\s*seconds?/i, // "Rate limited. Try again in 120 seconds."
|
|
30
|
+
];
|
|
31
|
+
|
|
19
32
|
function normalizeCommandTokens(runtime) {
|
|
20
33
|
if (Array.isArray(runtime?.command)) {
|
|
21
34
|
return runtime.command.flatMap((element) =>
|
|
@@ -37,10 +50,50 @@ export function isClaudeLocalCliRuntime(runtime) {
|
|
|
37
50
|
return head === 'claude' || head.endsWith('/claude');
|
|
38
51
|
}
|
|
39
52
|
|
|
53
|
+
export function isCodexLocalCliRuntime(runtime) {
|
|
54
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
55
|
+
if (tokens.length === 0) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const head = tokens[0].toLowerCase();
|
|
59
|
+
return head === 'codex' || head.endsWith('/codex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isCursorLocalCliRuntime(runtime) {
|
|
63
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
64
|
+
if (tokens.length === 0) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const head = tokens[0].toLowerCase();
|
|
68
|
+
return head === 'cursor' || head.endsWith('/cursor');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isWindsurfLocalCliRuntime(runtime) {
|
|
72
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
73
|
+
if (tokens.length === 0) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const head = tokens[0].toLowerCase();
|
|
77
|
+
return head === 'windsurf' || head.endsWith('/windsurf');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isOpenCodeLocalCliRuntime(runtime) {
|
|
81
|
+
const tokens = normalizeCommandTokens(runtime);
|
|
82
|
+
if (tokens.length === 0) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const head = tokens[0].toLowerCase();
|
|
86
|
+
return head === 'opencode' || head.endsWith('/opencode');
|
|
87
|
+
}
|
|
88
|
+
|
|
40
89
|
export function hasClaudeAuthenticationFailureText(text) {
|
|
41
90
|
return typeof text === 'string' && CLAUDE_AUTH_FAILURE_RE.test(text);
|
|
42
91
|
}
|
|
43
92
|
|
|
93
|
+
export function hasCodexAuthenticationFailureText(text) {
|
|
94
|
+
return typeof text === 'string' && CODEX_AUTH_FAILURE_RE.test(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
44
97
|
export function hasClaudeNodeIncompatibilityText(text) {
|
|
45
98
|
return typeof text === 'string' && CLAUDE_NODE_INCOMPATIBILITY_RE.test(text);
|
|
46
99
|
}
|
|
@@ -49,6 +102,41 @@ export function hasClaudeBareFlag(runtime) {
|
|
|
49
102
|
return normalizeCommandTokens(runtime).includes('--bare');
|
|
50
103
|
}
|
|
51
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Returns true if the text contains any provider rate-limit signature.
|
|
107
|
+
* Provider-agnostic — works for Claude, OpenAI/Codex, Cursor, Windsurf, OpenCode, and generic 429 patterns.
|
|
108
|
+
*/
|
|
109
|
+
export function hasProviderRateLimitText(text) {
|
|
110
|
+
return typeof text === 'string' && RATE_LIMIT_RE.test(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns true if any log line contains a rate-limit signature.
|
|
115
|
+
* Mirrors hasClaudeAuthFailureOutput / hasCodexAuthFailureOutput pattern.
|
|
116
|
+
*/
|
|
117
|
+
export function hasRateLimitOutput(logs) {
|
|
118
|
+
if (!Array.isArray(logs)) return false;
|
|
119
|
+
return logs.some((line) => hasProviderRateLimitText(line));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Best-effort extraction of reset delay from subprocess output text.
|
|
124
|
+
* Returns milliseconds or null if no reset time is found.
|
|
125
|
+
*/
|
|
126
|
+
export function parseRateLimitResetMs(text) {
|
|
127
|
+
if (typeof text !== 'string') return null;
|
|
128
|
+
for (const pattern of RESET_PATTERNS) {
|
|
129
|
+
const match = text.match(pattern);
|
|
130
|
+
if (match) {
|
|
131
|
+
const seconds = Number.parseInt(match[1], 10);
|
|
132
|
+
if (seconds > 0 && seconds <= 3600) {
|
|
133
|
+
return seconds * 1000;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
52
140
|
function parseNodeVersion(raw) {
|
|
53
141
|
const match = String(raw || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
54
142
|
if (!match) return null;
|
|
@@ -310,4 +398,4 @@ export async function runClaudeSmokeProbe(opts) {
|
|
|
310
398
|
});
|
|
311
399
|
}
|
|
312
400
|
|
|
313
|
-
export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };
|
|
401
|
+
export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens, RESET_PATTERNS };
|
|
@@ -28,6 +28,24 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
|
|
|
28
28
|
weak_flags: ['--full-auto'],
|
|
29
29
|
label: 'OpenAI Codex CLI',
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
binary: 'cursor',
|
|
33
|
+
authoritative_flag: '--background-agent',
|
|
34
|
+
weak_flags: [],
|
|
35
|
+
label: 'Cursor IDE',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
binary: 'windsurf',
|
|
39
|
+
authoritative_flag: '--agent',
|
|
40
|
+
weak_flags: [],
|
|
41
|
+
label: 'Windsurf IDE',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
binary: 'opencode',
|
|
45
|
+
authoritative_flag: '--non-interactive',
|
|
46
|
+
weak_flags: [],
|
|
47
|
+
label: 'OpenCode CLI',
|
|
48
|
+
},
|
|
31
49
|
];
|
|
32
50
|
|
|
33
51
|
/**
|
|
@@ -37,6 +55,9 @@ const KNOWN_CLI_AUTHORITY_FLAGS = [
|
|
|
37
55
|
const KNOWN_CLI_TRANSPORTS = {
|
|
38
56
|
claude: ['stdin'],
|
|
39
57
|
codex: ['argv', 'stdin'],
|
|
58
|
+
cursor: ['dispatch_bundle_only'],
|
|
59
|
+
windsurf: ['dispatch_bundle_only'],
|
|
60
|
+
opencode: ['stdin'],
|
|
40
61
|
};
|
|
41
62
|
|
|
42
63
|
function formatCommand(command, args = []) {
|
|
@@ -47,6 +47,7 @@ import { getDispatchLogPath, getTurnStagingResultPath } from './turn-paths.js';
|
|
|
47
47
|
import { reconcileOperatorHead } from './operator-commit-reconcile.js';
|
|
48
48
|
import { getContinuityStatus } from './continuity-status.js';
|
|
49
49
|
import { resolveGovernedRole } from './role-resolution.js';
|
|
50
|
+
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
50
51
|
import {
|
|
51
52
|
archiveStaleIntentsForRun,
|
|
52
53
|
formatLegacyIntentMigrationNotice,
|
|
@@ -63,7 +64,7 @@ import {
|
|
|
63
64
|
|
|
64
65
|
const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
|
|
65
66
|
const PRODUCTIVE_TIMEOUT_RETRY_MAX_PER_RUN = 1;
|
|
66
|
-
const PRODUCTIVE_TIMEOUT_RETRY_DEADLINE_MINUTES =
|
|
67
|
+
const PRODUCTIVE_TIMEOUT_RETRY_DEADLINE_MINUTES = 120;
|
|
67
68
|
const PROVIDER_REQUEST_TIMEOUT_RE = /request timed out|timed out waiting for (?:provider|api)|provider request timed out/i;
|
|
68
69
|
|
|
69
70
|
function getRoadmapReplenishmentTriageHints(root) {
|
|
@@ -636,9 +637,21 @@ function clearGhostBlockerAfterReissue(root, state) {
|
|
|
636
637
|
escalation: null,
|
|
637
638
|
};
|
|
638
639
|
writeGovernedState(root, nextState);
|
|
640
|
+
writeSessionCheckpoint(root, nextState, 'blocker_cleared');
|
|
639
641
|
return nextState;
|
|
640
642
|
}
|
|
641
643
|
|
|
644
|
+
function isGovernedRunStillActiveForSession(root, config, session) {
|
|
645
|
+
try {
|
|
646
|
+
const state = loadProjectState(root, config);
|
|
647
|
+
if (!state || state.status !== 'active') return false;
|
|
648
|
+
if (session.current_run_id && state.run_id && session.current_run_id !== state.run_id) return false;
|
|
649
|
+
return true;
|
|
650
|
+
} catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
642
655
|
/**
|
|
643
656
|
* Slice 2d (Turn 201): read the per-turn adapter dispatch log and return the
|
|
644
657
|
* most recent stderr excerpt + exit code + signal from `process_exit` or
|
|
@@ -1253,7 +1266,7 @@ function reconcileContinuousStartupState(context, session, contOpts, log) {
|
|
|
1253
1266
|
* @param {string} root
|
|
1254
1267
|
* @param {string} visionPath - Absolute path to VISION.md
|
|
1255
1268
|
* @param {{ triageApproval?: string }} options
|
|
1256
|
-
* @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, error?: string, idle?: boolean }}
|
|
1269
|
+
* @returns {{ ok: boolean, intentId?: string, section?: string, goal?: string, source?: string, error?: string, idle?: boolean }}
|
|
1257
1270
|
*/
|
|
1258
1271
|
export function seedFromVision(root, visionPath, options = {}) {
|
|
1259
1272
|
const roadmapResult = deriveRoadmapCandidates(root);
|
|
@@ -1313,6 +1326,9 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1313
1326
|
reason: 'roadmap-open-work auto-approval',
|
|
1314
1327
|
});
|
|
1315
1328
|
if (!approveResult.ok) {
|
|
1329
|
+
if (approveResult.error === 'scope_overlap_detected') {
|
|
1330
|
+
return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
|
|
1331
|
+
}
|
|
1316
1332
|
return { ok: false, error: `approve failed: ${approveResult.error}` };
|
|
1317
1333
|
}
|
|
1318
1334
|
}
|
|
@@ -1388,6 +1404,9 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1388
1404
|
reason: 'roadmap-replenishment auto-approval (BUG-77)',
|
|
1389
1405
|
});
|
|
1390
1406
|
if (!approveResult.ok) {
|
|
1407
|
+
if (approveResult.error === 'scope_overlap_detected') {
|
|
1408
|
+
return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
|
|
1409
|
+
}
|
|
1391
1410
|
return { ok: false, error: `approve failed: ${approveResult.error}` };
|
|
1392
1411
|
}
|
|
1393
1412
|
}
|
|
@@ -1402,6 +1421,15 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1402
1421
|
};
|
|
1403
1422
|
}
|
|
1404
1423
|
|
|
1424
|
+
if (exhaustion.reason === 'vision_fully_mapped' || exhaustion.reason === 'vision_no_actionable_scope') {
|
|
1425
|
+
return {
|
|
1426
|
+
ok: true,
|
|
1427
|
+
idle: true,
|
|
1428
|
+
source: 'vision_exhausted',
|
|
1429
|
+
reason: exhaustion.reason,
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1405
1433
|
const result = deriveVisionCandidates(root, visionPath);
|
|
1406
1434
|
if (!result.ok) {
|
|
1407
1435
|
return { ok: false, error: result.error };
|
|
@@ -1462,6 +1490,9 @@ export function seedFromVision(root, visionPath, options = {}) {
|
|
|
1462
1490
|
reason: 'vision-derived auto-approval',
|
|
1463
1491
|
});
|
|
1464
1492
|
if (!approveResult.ok) {
|
|
1493
|
+
if (approveResult.error === 'scope_overlap_detected') {
|
|
1494
|
+
return { ok: true, idle: true, deferred_reason: 'scope_overlap', overlap: approveResult.overlap };
|
|
1495
|
+
}
|
|
1465
1496
|
return { ok: false, error: `approve failed: ${approveResult.error}` };
|
|
1466
1497
|
}
|
|
1467
1498
|
}
|
|
@@ -2289,7 +2320,7 @@ export async function advanceContinuousRunOnce(context, session, contOpts, execu
|
|
|
2289
2320
|
if (seeded.source === 'roadmap_open_work') {
|
|
2290
2321
|
log(`Roadmap-derived: ${visionObjective}`);
|
|
2291
2322
|
} else if (seeded.source === 'roadmap_replenishment') {
|
|
2292
|
-
log(`Roadmap
|
|
2323
|
+
log(`Roadmap exhausted, vision still open, deriving next increment: ${visionObjective}`);
|
|
2293
2324
|
} else {
|
|
2294
2325
|
log(`Vision-derived: ${visionObjective}`);
|
|
2295
2326
|
}
|
|
@@ -2551,6 +2582,23 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
|
|
|
2551
2582
|
while (!stopping) {
|
|
2552
2583
|
const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
|
|
2553
2584
|
|
|
2585
|
+
if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
|
|
2586
|
+
session.status = 'running';
|
|
2587
|
+
writeContinuousSession(root, session);
|
|
2588
|
+
emitRunEvent(root, 'session_failed_recovered_active_run', {
|
|
2589
|
+
run_id: session.current_run_id || null,
|
|
2590
|
+
phase: null,
|
|
2591
|
+
status: 'active',
|
|
2592
|
+
payload: {
|
|
2593
|
+
session_id: session.session_id,
|
|
2594
|
+
failed_action: step.action || null,
|
|
2595
|
+
failed_reason: step.stop_reason || null,
|
|
2596
|
+
},
|
|
2597
|
+
});
|
|
2598
|
+
log('Session failure recovered - governed run still active, continuing.');
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2554
2602
|
// Terminal states
|
|
2555
2603
|
if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted' || step.status === 'session_budget') {
|
|
2556
2604
|
const terminalMessage = describeContinuousTerminalStep(step, contOpts);
|
|
@@ -287,14 +287,19 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
|
|
|
287
287
|
try {
|
|
288
288
|
const eventsPath = join(agentxchainDir, 'events.jsonl');
|
|
289
289
|
if (!existsSync(eventsPath)) return;
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
// Read as Buffer so .length is byte count, consistent with the
|
|
291
|
+
// byte-based lastEventsFileSize initialized at startup. The old
|
|
292
|
+
// code read as 'utf8' (string), whose .length is *character* count
|
|
293
|
+
// — a mismatch that caused a full-replay on the first invalidation
|
|
294
|
+
// when events contained multi-byte UTF-8 content.
|
|
295
|
+
const contentBuf = readFileSync(eventsPath);
|
|
296
|
+
if (contentBuf.length <= lastEventsFileSize) {
|
|
292
297
|
// File was truncated — reset and push all
|
|
293
|
-
if (
|
|
298
|
+
if (contentBuf.length < lastEventsFileSize) lastEventsFileSize = 0;
|
|
294
299
|
else return;
|
|
295
300
|
}
|
|
296
|
-
const newContent =
|
|
297
|
-
lastEventsFileSize =
|
|
301
|
+
const newContent = contentBuf.slice(lastEventsFileSize).toString('utf8');
|
|
302
|
+
lastEventsFileSize = contentBuf.length;
|
|
298
303
|
const lines = newContent.split('\n').filter(Boolean);
|
|
299
304
|
for (const line of lines) {
|
|
300
305
|
try {
|
|
@@ -797,6 +797,9 @@ function renderContext(state, config, root, turn, role) {
|
|
|
797
797
|
lines.push('');
|
|
798
798
|
lines.push(`- **Turn:** ${lastTurn.turn_id}`);
|
|
799
799
|
lines.push(`- **Role:** ${lastTurn.role}`);
|
|
800
|
+
if (lastTurn.runtime_id) {
|
|
801
|
+
lines.push(`- **Runtime:** ${lastTurn.runtime_id}`);
|
|
802
|
+
}
|
|
800
803
|
lines.push(`- **Summary:** ${lastTurn.summary}`);
|
|
801
804
|
if (lastTurn.decisions?.length) {
|
|
802
805
|
lines.push('- **Decisions:**');
|
|
@@ -1412,12 +1415,13 @@ function renderDecisionHistory(root, warnings = []) {
|
|
|
1412
1415
|
const lines = [];
|
|
1413
1416
|
lines.push('## Decision History');
|
|
1414
1417
|
lines.push('');
|
|
1415
|
-
lines.push('| ID | Phase | Role | Statement |');
|
|
1416
|
-
lines.push('
|
|
1418
|
+
lines.push('| ID | Phase | Role | Runtime | Statement |');
|
|
1419
|
+
lines.push('|----|-------|------|---------|-----------|');
|
|
1417
1420
|
for (const d of displayed) {
|
|
1418
1421
|
// Escape pipes in statement to avoid breaking the table
|
|
1419
1422
|
const stmt = (d.statement || '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
1420
|
-
|
|
1423
|
+
const runtimeId = (d.runtime_id || '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
1424
|
+
lines.push(`| ${d.id} | ${d.phase || ''} | ${d.role || ''} | ${runtimeId} | ${stmt} |`);
|
|
1421
1425
|
}
|
|
1422
1426
|
if (totalCount > DECISION_HISTORY_MAX_ENTRIES) {
|
|
1423
1427
|
lines.push('');
|
|
@@ -199,6 +199,15 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
|
|
|
199
199
|
writeProgress();
|
|
200
200
|
},
|
|
201
201
|
|
|
202
|
+
/** Record adapter keepalive without treating it as governed startup proof. */
|
|
203
|
+
heartbeat(summary = 'Adapter keepalive') {
|
|
204
|
+
state.activity_type = 'heartbeat';
|
|
205
|
+
state.activity_summary = summary;
|
|
206
|
+
state.last_activity_at = new Date().toISOString();
|
|
207
|
+
dirty = true;
|
|
208
|
+
maybeWrite();
|
|
209
|
+
},
|
|
210
|
+
|
|
202
211
|
/** Update PID after spawn (local_cli). */
|
|
203
212
|
setPid(newPid) {
|
|
204
213
|
state.pid = newPid;
|
|
@@ -3672,7 +3672,7 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
|
|
|
3672
3672
|
const baseline = captureBaseline(root);
|
|
3673
3673
|
|
|
3674
3674
|
const now = new Date().toISOString();
|
|
3675
|
-
const timeoutMinutes =
|
|
3675
|
+
const timeoutMinutes = config?.timeouts?.per_turn_minutes || 120;
|
|
3676
3676
|
const nextSequence = (state.turn_sequence || 0) + 1;
|
|
3677
3677
|
|
|
3678
3678
|
// Record which turns are concurrent siblings (for conflict detection context)
|
|
@@ -3954,7 +3954,7 @@ export function reissueTurn(root, config, opts = {}) {
|
|
|
3954
3954
|
|
|
3955
3955
|
// Create the new turn
|
|
3956
3956
|
const newTurnId = `turn_${randomBytes(8).toString('hex')}`;
|
|
3957
|
-
const timeoutMinutes =
|
|
3957
|
+
const timeoutMinutes = config?.timeouts?.per_turn_minutes || 120;
|
|
3958
3958
|
const nextSequence = (state.turn_sequence || 0) + 1;
|
|
3959
3959
|
|
|
3960
3960
|
const newTurn = {
|
|
@@ -4868,7 +4868,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4868
4868
|
|
|
4869
4869
|
const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
|
|
4870
4870
|
const verificationReplay = (config.policies || []).some((policy) => policy?.rule === 'require_reproducible_verification')
|
|
4871
|
-
? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
|
|
4871
|
+
? replayVerificationMachineEvidence({ root, verification: turnResult.verification, allowCommandExecution: true })
|
|
4872
4872
|
: null;
|
|
4873
4873
|
|
|
4874
4874
|
if (verificationReplay?.workspace_guard?.ok === false) {
|
|
@@ -5238,6 +5238,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
5238
5238
|
turn_id: turnResult.turn_id,
|
|
5239
5239
|
role: turnResult.role,
|
|
5240
5240
|
phase: state.phase,
|
|
5241
|
+
runtime_id: turnResult.runtime_id,
|
|
5241
5242
|
category: decision.category,
|
|
5242
5243
|
statement: decision.statement,
|
|
5243
5244
|
rationale: decision.rationale,
|
|
@@ -6488,7 +6489,7 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
6488
6489
|
const retryStartedAt = new Date().toISOString();
|
|
6489
6490
|
retryTurn.baseline = captureBaseline(root);
|
|
6490
6491
|
retryTurn.started_at = retryStartedAt;
|
|
6491
|
-
retryTurn.deadline_at = new Date(Date.now() +
|
|
6492
|
+
retryTurn.deadline_at = new Date(Date.now() + (config?.timeouts?.per_turn_minutes || 120) * 60 * 1000).toISOString();
|
|
6492
6493
|
|
|
6493
6494
|
if (isConflictReject) {
|
|
6494
6495
|
retryTurn.assigned_sequence = Math.max(
|
package/src/lib/intake.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
derivePhaseScopeFromIntentMetadata,
|
|
29
29
|
getPhaseOrder,
|
|
30
30
|
} from './intent-phase-scope.js';
|
|
31
|
+
import { checkIntentScopeOverlap } from './scope-overlap.js';
|
|
31
32
|
|
|
32
33
|
const VALID_SOURCES = ['manual', 'ci_failure', 'git_ref_change', 'schedule', 'vision_scan', 'vision_idle_expansion'];
|
|
33
34
|
const VALID_PRIORITIES = ['p0', 'p1', 'p2', 'p3'];
|
|
@@ -886,6 +887,25 @@ export function approveIntent(root, intentId, options = {}) {
|
|
|
886
887
|
return { ok: false, error: `cannot approve from status "${intent.status}" (must be triaged or blocked)`, exitCode: 1 };
|
|
887
888
|
}
|
|
888
889
|
|
|
890
|
+
// --- Scope overlap guard (M10) ---
|
|
891
|
+
if (!options.forceScope) {
|
|
892
|
+
const charterText = intent.charter || '';
|
|
893
|
+
const acceptanceItems = Array.isArray(intent.acceptance_contract) ? intent.acceptance_contract : [];
|
|
894
|
+
if (charterText) {
|
|
895
|
+
const overlapCheck = checkIntentScopeOverlap(root, charterText, acceptanceItems, {
|
|
896
|
+
threshold: options.scopeOverlapThreshold ?? 0.4,
|
|
897
|
+
});
|
|
898
|
+
if (overlapCheck.overlapping) {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
error: 'scope_overlap_detected',
|
|
902
|
+
overlap: overlapCheck,
|
|
903
|
+
exitCode: 3,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
889
909
|
const approver = options.approver || 'operator';
|
|
890
910
|
const previousStatus = intent.status;
|
|
891
911
|
const reason = options.reason || (previousStatus === 'blocked' ? 're-approved after block resolution' : 'approved for planning');
|
|
@@ -1077,13 +1097,19 @@ export function startIntent(root, intentId, options = {}) {
|
|
|
1077
1097
|
|
|
1078
1098
|
// Load governed state
|
|
1079
1099
|
const statePath = join(root, STATE_PATH);
|
|
1100
|
+
let state;
|
|
1080
1101
|
if (!existsSync(statePath)) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1102
|
+
// RB-11: cold-start. No state.json yet — synthesize an idle state so the
|
|
1103
|
+
// bootstrap below initializes a fresh governed run, matching the `run`
|
|
1104
|
+
// command. Without this, `intake start` (and continuous mode, which starts
|
|
1105
|
+
// its first derived intent via this path) cannot start lights-out from a
|
|
1106
|
+
// clean initialized project.
|
|
1107
|
+
state = { status: 'idle', run_id: null };
|
|
1108
|
+
} else {
|
|
1109
|
+
state = loadProjectState(root, config);
|
|
1110
|
+
if (!state) {
|
|
1111
|
+
return { ok: false, error: 'Failed to parse governed state.json', exitCode: 2 };
|
|
1112
|
+
}
|
|
1087
1113
|
}
|
|
1088
1114
|
|
|
1089
1115
|
const allowCompletedRestart = options.allowTerminalRestart === true
|
|
@@ -444,6 +444,8 @@ export function validateV4Config(data, projectRoot) {
|
|
|
444
444
|
}
|
|
445
445
|
if (rt.type === 'local_cli') {
|
|
446
446
|
validateRuntimePositiveInteger(`Runtime "${id}": startup_watchdog_ms`, rt.startup_watchdog_ms, errors);
|
|
447
|
+
validateRuntimePositiveInteger(`Runtime "${id}": startup_heartbeat_ms`, rt.startup_heartbeat_ms, errors);
|
|
448
|
+
validateRuntimePositiveInteger(`Runtime "${id}": liveness_heartbeat_ms`, rt.liveness_heartbeat_ms, errors);
|
|
447
449
|
}
|
|
448
450
|
// Validate prompt_transport for local_cli runtimes
|
|
449
451
|
if (rt.type === 'local_cli' && rt.prompt_transport) {
|
|
@@ -639,6 +641,8 @@ export function validateRunLoopConfig(runLoop) {
|
|
|
639
641
|
return errors;
|
|
640
642
|
}
|
|
641
643
|
validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
|
|
644
|
+
validateRunLoopPositiveInteger('run_loop.startup_heartbeat_ms', runLoop.startup_heartbeat_ms, errors);
|
|
645
|
+
validateRunLoopPositiveInteger('run_loop.liveness_heartbeat_ms', runLoop.liveness_heartbeat_ms, errors);
|
|
642
646
|
validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
|
|
643
647
|
if (runLoop.continuous !== undefined && runLoop.continuous !== null) {
|
|
644
648
|
validateRunLoopContinuousConfig('run_loop.continuous', runLoop.continuous, errors);
|