agentxchain 2.83.0 → 2.85.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/builtin-plugins/plugin-github-issues/README.md +13 -1
- package/builtin-plugins/plugin-json-report/README.md +14 -1
- package/package.json +1 -1
- package/scripts/model-compatibility-probe.mjs +312 -0
- package/scripts/release-bump.sh +5 -9
- package/src/commands/config.js +6 -0
- package/src/commands/doctor.js +4 -1
- package/src/commands/run.js +1 -0
- package/src/lib/normalized-config.js +81 -6
- package/src/lib/run-loop.js +269 -60
- package/src/lib/validation.js +4 -0
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Built-in AgentXchain plugin that mirrors governed run status into a configured GitHub issue.
|
|
4
4
|
|
|
5
|
-
Install
|
|
5
|
+
Install by short name (recommended):
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
agentxchain plugin install github-issues \
|
|
9
|
+
--config '{"repo":"owner/name","issue_number":42}'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Install from local path:
|
|
6
13
|
|
|
7
14
|
```bash
|
|
8
15
|
agentxchain plugin install ./plugins/plugin-github-issues
|
|
@@ -29,3 +36,8 @@ Scope notes:
|
|
|
29
36
|
- One plugin-owned comment per run, updated in place
|
|
30
37
|
- Managed labels track phase or blocked state only
|
|
31
38
|
- This plugin does **not** close issues or claim post-gate approval state because the hook surface does not provide post-gate truth
|
|
39
|
+
|
|
40
|
+
Proof surfaces:
|
|
41
|
+
|
|
42
|
+
- Continuous subprocess proof: `cli/test/e2e-builtin-github-issues.test.js`
|
|
43
|
+
- Live product-example proof: `examples/governed-todo-app/run-github-issues-proof.mjs`
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Built-in AgentXchain plugin that writes structured lifecycle report artifacts into `.agentxchain/reports/`.
|
|
4
4
|
|
|
5
|
-
Install
|
|
5
|
+
Install by built-in short name (recommended):
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
agentxchain plugin install json-report
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Install from a repo-local path:
|
|
6
12
|
|
|
7
13
|
```bash
|
|
8
14
|
agentxchain plugin install ./plugins/plugin-json-report
|
|
@@ -15,6 +21,12 @@ agentxchain plugin install ./plugins/plugin-json-report \
|
|
|
15
21
|
--config '{"report_dir":".agentxchain/custom-reports"}'
|
|
16
22
|
```
|
|
17
23
|
|
|
24
|
+
Live proof command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node examples/governed-todo-app/run-json-report-proof.mjs --json
|
|
28
|
+
```
|
|
29
|
+
|
|
18
30
|
Hook phases:
|
|
19
31
|
|
|
20
32
|
- `after_acceptance`
|
|
@@ -28,3 +40,4 @@ Outputs:
|
|
|
28
40
|
- `latest-<hook_phase>.json`
|
|
29
41
|
- default output path `.agentxchain/reports`
|
|
30
42
|
- `report_dir` may override the path, but it must stay inside the governed project root
|
|
43
|
+
- `latest.json` reflects the most recent hook invocation
|
package/package.json
CHANGED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Model Compatibility Probe — api_proxy + proposed write authority
|
|
4
|
+
*
|
|
5
|
+
* Dispatches a lightweight governed turn to each target model and records
|
|
6
|
+
* whether the model can produce a well-formed turn result with proposed_changes.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node cli/scripts/model-compatibility-probe.mjs
|
|
10
|
+
* node cli/scripts/model-compatibility-probe.mjs --json
|
|
11
|
+
*
|
|
12
|
+
* Requires ANTHROPIC_API_KEY in the environment.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
|
16
|
+
|
|
17
|
+
const SYSTEM_PROMPT = [
|
|
18
|
+
'You are acting as a governed agent in an AgentXchain protocol run.',
|
|
19
|
+
'Your task and rules are described in the user message.',
|
|
20
|
+
'You MUST respond with a valid JSON object matching the turn result schema provided in the prompt.',
|
|
21
|
+
'Do NOT wrap the JSON in markdown code fences. Respond with raw JSON only.',
|
|
22
|
+
].join('\n');
|
|
23
|
+
|
|
24
|
+
const PROBE_PROMPT = `You are a governed agent with write_authority: "proposed".
|
|
25
|
+
|
|
26
|
+
Your task: create a single file called \`probe-result.txt\` containing the text "model compatibility probe passed".
|
|
27
|
+
|
|
28
|
+
Respond with a single raw JSON object (no markdown fences) matching this exact schema:
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
"schema_version": "1.0",
|
|
32
|
+
"run_id": "probe_run",
|
|
33
|
+
"turn_id": "probe_turn_001",
|
|
34
|
+
"role": "dev",
|
|
35
|
+
"runtime_id": "probe_runtime",
|
|
36
|
+
"status": "completed",
|
|
37
|
+
"summary": "Created probe-result.txt for model compatibility verification.",
|
|
38
|
+
"decisions": [],
|
|
39
|
+
"objections": [],
|
|
40
|
+
"files_changed": ["probe-result.txt"],
|
|
41
|
+
"verification": { "status": "pass", "evidence": "File created successfully." },
|
|
42
|
+
"artifact": { "type": "patch", "ref": null },
|
|
43
|
+
"proposed_next_role": "human",
|
|
44
|
+
"proposed_changes": [
|
|
45
|
+
{
|
|
46
|
+
"path": "probe-result.txt",
|
|
47
|
+
"action": "create",
|
|
48
|
+
"content": "model compatibility probe passed"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Return ONLY the JSON object. No explanation, no markdown fences, no extra text.`;
|
|
54
|
+
|
|
55
|
+
const MODELS = [
|
|
56
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', maxTokens: 2048, costInput: 1.00, costOutput: 5.00 },
|
|
57
|
+
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', maxTokens: 2048, costInput: 3.00, costOutput: 15.00 },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function extractTurnResult(text) {
|
|
61
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
62
|
+
return { ok: false, error: 'Empty response text' };
|
|
63
|
+
}
|
|
64
|
+
const trimmed = text.trim();
|
|
65
|
+
|
|
66
|
+
// Direct JSON parse
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(trimmed);
|
|
69
|
+
if (parsed && typeof parsed === 'object' && parsed.schema_version) {
|
|
70
|
+
return { ok: true, turnResult: parsed, method: 'direct' };
|
|
71
|
+
}
|
|
72
|
+
} catch { /* not pure JSON */ }
|
|
73
|
+
|
|
74
|
+
// Markdown fence extraction
|
|
75
|
+
const fenceMatch = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
|
76
|
+
if (fenceMatch) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(fenceMatch[1].trim());
|
|
79
|
+
if (parsed && typeof parsed === 'object' && parsed.schema_version) {
|
|
80
|
+
return { ok: true, turnResult: parsed, method: 'fence' };
|
|
81
|
+
}
|
|
82
|
+
} catch { /* invalid JSON inside fence */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Substring extraction
|
|
86
|
+
const jsonStart = trimmed.indexOf('{');
|
|
87
|
+
const jsonEnd = trimmed.lastIndexOf('}');
|
|
88
|
+
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(trimmed.slice(jsonStart, jsonEnd + 1));
|
|
91
|
+
if (parsed && typeof parsed === 'object' && parsed.schema_version) {
|
|
92
|
+
return { ok: true, turnResult: parsed, method: 'substring' };
|
|
93
|
+
}
|
|
94
|
+
} catch { /* not valid JSON */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { ok: false, error: 'Could not extract structured turn result JSON' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateProposedChanges(turnResult) {
|
|
101
|
+
const changes = turnResult?.proposed_changes;
|
|
102
|
+
if (!Array.isArray(changes) || changes.length === 0) {
|
|
103
|
+
return { present: false, wellFormed: false, reason: 'proposed_changes missing or empty' };
|
|
104
|
+
}
|
|
105
|
+
for (const c of changes) {
|
|
106
|
+
if (!c.path || typeof c.path !== 'string') {
|
|
107
|
+
return { present: true, wellFormed: false, reason: `entry missing path` };
|
|
108
|
+
}
|
|
109
|
+
if (!['create', 'modify', 'delete'].includes(c.action)) {
|
|
110
|
+
return { present: true, wellFormed: false, reason: `invalid action: ${c.action}` };
|
|
111
|
+
}
|
|
112
|
+
if ((c.action === 'create' || c.action === 'modify') && (!c.content || typeof c.content !== 'string')) {
|
|
113
|
+
return { present: true, wellFormed: false, reason: `${c.action} entry missing content for ${c.path}` };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { present: true, wellFormed: true, count: changes.length };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function probeModel(model, apiKey) {
|
|
120
|
+
const startMs = Date.now();
|
|
121
|
+
const body = {
|
|
122
|
+
model: model.id,
|
|
123
|
+
max_tokens: model.maxTokens,
|
|
124
|
+
system: SYSTEM_PROMPT,
|
|
125
|
+
messages: [{ role: 'user', content: PROBE_PROMPT }],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
let responseData;
|
|
129
|
+
let rawError = null;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(ANTHROPIC_ENDPOINT, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'x-api-key': apiKey,
|
|
137
|
+
'anthropic-version': '2023-06-01',
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const errorBody = await res.text().catch(() => '');
|
|
144
|
+
rawError = `HTTP ${res.status}: ${errorBody.slice(0, 500)}`;
|
|
145
|
+
return {
|
|
146
|
+
model: model.id,
|
|
147
|
+
label: model.label,
|
|
148
|
+
extraction_success: false,
|
|
149
|
+
schema_valid: false,
|
|
150
|
+
proposed_changes_present: false,
|
|
151
|
+
proposed_changes_well_formed: false,
|
|
152
|
+
latency_ms: Date.now() - startMs,
|
|
153
|
+
cost_usd: 0,
|
|
154
|
+
classification: 'unsupported',
|
|
155
|
+
raw_error: rawError,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
responseData = await res.json();
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return {
|
|
162
|
+
model: model.id,
|
|
163
|
+
label: model.label,
|
|
164
|
+
extraction_success: false,
|
|
165
|
+
schema_valid: false,
|
|
166
|
+
proposed_changes_present: false,
|
|
167
|
+
proposed_changes_well_formed: false,
|
|
168
|
+
latency_ms: Date.now() - startMs,
|
|
169
|
+
cost_usd: 0,
|
|
170
|
+
classification: 'unsupported',
|
|
171
|
+
raw_error: err.message,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const latencyMs = Date.now() - startMs;
|
|
176
|
+
|
|
177
|
+
// Extract text from Anthropic response
|
|
178
|
+
const textBlock = responseData?.content?.find(b => b.type === 'text');
|
|
179
|
+
const responseText = textBlock?.text || '';
|
|
180
|
+
|
|
181
|
+
// Extract turn result
|
|
182
|
+
const extraction = extractTurnResult(responseText);
|
|
183
|
+
|
|
184
|
+
// Calculate cost
|
|
185
|
+
const usage = responseData?.usage || {};
|
|
186
|
+
const inputTokens = usage.input_tokens || 0;
|
|
187
|
+
const outputTokens = usage.output_tokens || 0;
|
|
188
|
+
const costUsd = (inputTokens / 1_000_000) * model.costInput + (outputTokens / 1_000_000) * model.costOutput;
|
|
189
|
+
|
|
190
|
+
if (!extraction.ok) {
|
|
191
|
+
return {
|
|
192
|
+
model: model.id,
|
|
193
|
+
label: model.label,
|
|
194
|
+
extraction_success: false,
|
|
195
|
+
extraction_method: null,
|
|
196
|
+
schema_valid: false,
|
|
197
|
+
proposed_changes_present: false,
|
|
198
|
+
proposed_changes_well_formed: false,
|
|
199
|
+
latency_ms: latencyMs,
|
|
200
|
+
input_tokens: inputTokens,
|
|
201
|
+
output_tokens: outputTokens,
|
|
202
|
+
cost_usd: Math.round(costUsd * 1_000_000) / 1_000_000,
|
|
203
|
+
classification: 'unsupported',
|
|
204
|
+
raw_error: extraction.error,
|
|
205
|
+
response_preview: responseText.slice(0, 300),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const tr = extraction.turnResult;
|
|
210
|
+
const schemaValid = typeof tr.schema_version === 'string' && tr.schema_version === '1.0';
|
|
211
|
+
const pcValidation = validateProposedChanges(tr);
|
|
212
|
+
|
|
213
|
+
let classification;
|
|
214
|
+
if (schemaValid && pcValidation.present && pcValidation.wellFormed) {
|
|
215
|
+
classification = 'reliable';
|
|
216
|
+
} else if (extraction.ok && (!pcValidation.present || !pcValidation.wellFormed)) {
|
|
217
|
+
classification = 'inconsistent';
|
|
218
|
+
} else {
|
|
219
|
+
classification = 'unsupported';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
model: model.id,
|
|
224
|
+
label: model.label,
|
|
225
|
+
extraction_success: true,
|
|
226
|
+
extraction_method: extraction.method,
|
|
227
|
+
schema_valid: schemaValid,
|
|
228
|
+
proposed_changes_present: pcValidation.present,
|
|
229
|
+
proposed_changes_well_formed: pcValidation.wellFormed,
|
|
230
|
+
proposed_changes_count: pcValidation.count || 0,
|
|
231
|
+
latency_ms: latencyMs,
|
|
232
|
+
input_tokens: inputTokens,
|
|
233
|
+
output_tokens: outputTokens,
|
|
234
|
+
cost_usd: Math.round(costUsd * 1_000_000) / 1_000_000,
|
|
235
|
+
classification,
|
|
236
|
+
raw_error: pcValidation.reason || null,
|
|
237
|
+
status_returned: tr.status,
|
|
238
|
+
summary_returned: typeof tr.summary === 'string' ? tr.summary.slice(0, 100) : null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function main() {
|
|
243
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
244
|
+
if (!apiKey) {
|
|
245
|
+
console.error('ANTHROPIC_API_KEY not set');
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const jsonMode = process.argv.includes('--json');
|
|
250
|
+
|
|
251
|
+
if (!jsonMode) {
|
|
252
|
+
console.log('AgentXchain Model Compatibility Probe');
|
|
253
|
+
console.log('Provider: Anthropic | Write Authority: proposed');
|
|
254
|
+
console.log('─'.repeat(60));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const results = [];
|
|
258
|
+
|
|
259
|
+
for (const model of MODELS) {
|
|
260
|
+
if (!jsonMode) {
|
|
261
|
+
process.stdout.write(`Probing ${model.label} (${model.id})... `);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = await probeModel(model, apiKey);
|
|
265
|
+
results.push(result);
|
|
266
|
+
|
|
267
|
+
if (!jsonMode) {
|
|
268
|
+
const icon = result.classification === 'reliable' ? '✓' : result.classification === 'inconsistent' ? '~' : '✗';
|
|
269
|
+
console.log(`${icon} ${result.classification} (${result.latency_ms}ms, $${result.cost_usd})`);
|
|
270
|
+
if (result.raw_error) {
|
|
271
|
+
console.log(` └─ ${result.raw_error}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const output = {
|
|
277
|
+
probe_version: '1.0',
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
provider: 'anthropic',
|
|
280
|
+
write_authority: 'proposed',
|
|
281
|
+
models: results,
|
|
282
|
+
total_cost_usd: results.reduce((s, r) => s + r.cost_usd, 0),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if (jsonMode) {
|
|
286
|
+
console.log(JSON.stringify(output, null, 2));
|
|
287
|
+
} else {
|
|
288
|
+
console.log('─'.repeat(60));
|
|
289
|
+
console.log(`Total cost: $${output.total_cost_usd.toFixed(6)}`);
|
|
290
|
+
console.log();
|
|
291
|
+
console.log('Matrix:');
|
|
292
|
+
for (const r of results) {
|
|
293
|
+
console.log(` ${r.label.padEnd(12)} ${r.classification.padEnd(14)} extraction=${r.extraction_success} schema=${r.schema_valid} proposed=${r.proposed_changes_well_formed}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Write results to .planning/ for durable reference
|
|
298
|
+
const { writeFileSync, mkdirSync } = await import('node:fs');
|
|
299
|
+
const { dirname, join } = await import('node:path');
|
|
300
|
+
const { fileURLToPath } = await import('node:url');
|
|
301
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
302
|
+
const outPath = join(repoRoot, '.planning', 'MODEL_COMPATIBILITY_RESULTS.json');
|
|
303
|
+
writeFileSync(outPath, JSON.stringify(output, null, 2) + '\n');
|
|
304
|
+
if (!jsonMode) {
|
|
305
|
+
console.log(`\nResults written to: .planning/MODEL_COMPATIBILITY_RESULTS.json`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
main().catch(err => {
|
|
310
|
+
console.error(err);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
});
|
package/scripts/release-bump.sh
CHANGED
|
@@ -62,7 +62,6 @@ ALLOWED_RELEASE_PATHS=(
|
|
|
62
62
|
"website-v2/docs/protocol-implementor-guide.mdx"
|
|
63
63
|
".planning/LAUNCH_EVIDENCE_REPORT.md"
|
|
64
64
|
"website-v2/static/llms.txt"
|
|
65
|
-
"website-v2/static/sitemap.xml"
|
|
66
65
|
"cli/homebrew/agentxchain.rb"
|
|
67
66
|
"cli/homebrew/README.md"
|
|
68
67
|
)
|
|
@@ -148,9 +147,9 @@ if [[ ! -f "${REPO_ROOT}/${RELEASE_DOC_PATH}" ]]; then
|
|
|
148
147
|
SURFACE_ERRORS+=("release notes page missing: ${RELEASE_DOC_PATH}")
|
|
149
148
|
fi
|
|
150
149
|
|
|
151
|
-
# 4c. Docs sidebar
|
|
152
|
-
if ! grep -q "
|
|
153
|
-
SURFACE_ERRORS+=("sidebars.ts does not
|
|
150
|
+
# 4c. Docs sidebar auto-generates releases from dirName (release doc existence is sufficient)
|
|
151
|
+
if ! grep -q "dirName.*releases" "${REPO_ROOT}/website-v2/sidebars.ts" 2>/dev/null; then
|
|
152
|
+
SURFACE_ERRORS+=("sidebars.ts does not auto-generate releases (missing dirName: 'releases')")
|
|
154
153
|
fi
|
|
155
154
|
|
|
156
155
|
# 4d. Homepage hero badge shows target version
|
|
@@ -181,10 +180,7 @@ if ! grep -q "${CURRENT_RELEASE_ROUTE}" "${REPO_ROOT}/website-v2/static/llms.txt
|
|
|
181
180
|
SURFACE_ERRORS+=("website-v2/static/llms.txt does not list '${CURRENT_RELEASE_ROUTE}'")
|
|
182
181
|
fi
|
|
183
182
|
|
|
184
|
-
# 4i. sitemap.xml
|
|
185
|
-
if ! grep -q "${CURRENT_RELEASE_ROUTE}" "${REPO_ROOT}/website-v2/static/sitemap.xml" 2>/dev/null; then
|
|
186
|
-
SURFACE_ERRORS+=("website-v2/static/sitemap.xml does not list '${CURRENT_RELEASE_ROUTE}'")
|
|
187
|
-
fi
|
|
183
|
+
# 4i. sitemap.xml is now auto-generated by Docusaurus at build time — no static file check needed
|
|
188
184
|
|
|
189
185
|
if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
|
|
190
186
|
echo "FAIL: ${#SURFACE_ERRORS[@]} version-surface(s) not aligned to ${TARGET_VERSION}:" >&2
|
|
@@ -194,7 +190,7 @@ if [[ "${#SURFACE_ERRORS[@]}" -gt 0 ]]; then
|
|
|
194
190
|
echo "create release identity when governed surfaces are stale." >&2
|
|
195
191
|
exit 1
|
|
196
192
|
fi
|
|
197
|
-
echo " OK: all
|
|
193
|
+
echo " OK: all 8 governed version surfaces reference ${TARGET_VERSION}"
|
|
198
194
|
|
|
199
195
|
# 5. Auto-align Homebrew mirror to target version
|
|
200
196
|
# The formula URL and README version/tarball are updated automatically.
|
package/src/commands/config.js
CHANGED
|
@@ -219,6 +219,12 @@ function setSetting(config, configPath, keyValPair, context) {
|
|
|
219
219
|
console.log('');
|
|
220
220
|
console.log(chalk.green(` ✓ Set ${chalk.bold(key)} = ${val}`));
|
|
221
221
|
if (oldVal !== undefined) console.log(chalk.dim(` (was: ${oldVal})`));
|
|
222
|
+
if ((validation.warnings || []).length > 0) {
|
|
223
|
+
console.log(chalk.yellow(' Warnings:'));
|
|
224
|
+
for (const warning of validation.warnings) {
|
|
225
|
+
console.log(chalk.dim(` - ${warning}`));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
222
228
|
console.log('');
|
|
223
229
|
}
|
|
224
230
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -51,8 +51,11 @@ function governedDoctor(root, rawConfig, opts) {
|
|
|
51
51
|
|
|
52
52
|
// 1. Config validation
|
|
53
53
|
const configResult = loadNormalizedConfig(rawConfig, root);
|
|
54
|
-
if (configResult.ok) {
|
|
54
|
+
if (configResult.ok && (configResult.warnings || []).length === 0) {
|
|
55
55
|
checks.push({ id: 'config_valid', name: 'Config validation', level: 'pass', detail: 'Config loads and validates' });
|
|
56
|
+
} else if (configResult.ok) {
|
|
57
|
+
const warningSummary = configResult.warnings.slice(0, 2).join('; ');
|
|
58
|
+
checks.push({ id: 'config_valid', name: 'Config validation', level: 'warn', detail: warningSummary });
|
|
56
59
|
} else {
|
|
57
60
|
const errorSummary = configResult.errors.slice(0, 3).join('; ');
|
|
58
61
|
checks.push({ id: 'config_valid', name: 'Config validation', level: 'fail', detail: errorSummary });
|
package/src/commands/run.js
CHANGED
|
@@ -330,9 +330,10 @@ export function detectConfigVersion(raw) {
|
|
|
330
330
|
*/
|
|
331
331
|
export function validateV4Config(data, projectRoot) {
|
|
332
332
|
const errors = [];
|
|
333
|
+
const warnings = [];
|
|
333
334
|
|
|
334
335
|
if (!data || typeof data !== 'object') {
|
|
335
|
-
return { ok: false, errors: ['Config must be a JSON object'] };
|
|
336
|
+
return { ok: false, errors: ['Config must be a JSON object'], warnings };
|
|
336
337
|
}
|
|
337
338
|
|
|
338
339
|
// Top-level required sections
|
|
@@ -555,7 +556,80 @@ export function validateV4Config(data, projectRoot) {
|
|
|
555
556
|
errors.push(...timeoutValidation.errors);
|
|
556
557
|
}
|
|
557
558
|
|
|
558
|
-
|
|
559
|
+
warnings.push(...collectRemoteReviewOnlyGateWarnings(data));
|
|
560
|
+
|
|
561
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function collectRemoteReviewOnlyGateWarnings(data) {
|
|
565
|
+
const warnings = [];
|
|
566
|
+
const routing = data?.routing;
|
|
567
|
+
const gates = data?.gates;
|
|
568
|
+
const roles = data?.roles;
|
|
569
|
+
const runtimes = data?.runtimes;
|
|
570
|
+
|
|
571
|
+
if (!routing || !gates || !roles || !runtimes) {
|
|
572
|
+
return warnings;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
576
|
+
const exitGateId = route?.exit_gate;
|
|
577
|
+
if (!exitGateId || !gates[exitGateId]) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const requiredFiles = Array.isArray(gates[exitGateId]?.requires_files)
|
|
582
|
+
? gates[exitGateId].requires_files.filter(filePath => typeof filePath === 'string' && filePath.trim())
|
|
583
|
+
: [];
|
|
584
|
+
if (requiredFiles.length === 0) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const candidateRoleIds = [
|
|
589
|
+
route?.entry_role,
|
|
590
|
+
...(Array.isArray(route?.allowed_next_roles) ? route.allowed_next_roles : []),
|
|
591
|
+
].filter((roleId) => roleId && roleId !== 'human');
|
|
592
|
+
|
|
593
|
+
if (candidateRoleIds.length === 0) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const candidateRoles = [...new Set(candidateRoleIds)]
|
|
598
|
+
.map((roleId) => {
|
|
599
|
+
const role = roles[roleId];
|
|
600
|
+
const runtime = role?.runtime ? runtimes[role.runtime] : null;
|
|
601
|
+
if (!role || !runtime) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
return { roleId, role, runtime };
|
|
605
|
+
})
|
|
606
|
+
.filter(Boolean);
|
|
607
|
+
|
|
608
|
+
if (candidateRoles.length === 0) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const hasFileProducingRole = candidateRoles.some(({ role }) =>
|
|
613
|
+
role.write_authority === 'authoritative' || role.write_authority === 'proposed');
|
|
614
|
+
if (hasFileProducingRole) {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const allRemoteReviewOnly = candidateRoles.every(({ role, runtime }) =>
|
|
619
|
+
role.write_authority === 'review_only' && (runtime.type === 'api_proxy' || runtime.type === 'remote_agent'));
|
|
620
|
+
if (!allRemoteReviewOnly) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const roleSummary = candidateRoles
|
|
625
|
+
.map(({ roleId, runtime }) => `${roleId}:${runtime.type}`)
|
|
626
|
+
.join(', ');
|
|
627
|
+
warnings.push(
|
|
628
|
+
`Routing "${phase}" exits through gate "${exitGateId}" with requires_files (${requiredFiles.join(', ')}) but all participating roles are review_only remote runtimes (${roleSummary}). Those files cannot be produced through governed turns; add a proposed/authoritative writer, remove the gate files, or expect operator-managed out-of-band artifacts.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return warnings;
|
|
559
633
|
}
|
|
560
634
|
|
|
561
635
|
export function validateBudgetConfig(budget) {
|
|
@@ -1133,6 +1207,7 @@ export function loadNormalizedConfig(raw, projectRoot) {
|
|
|
1133
1207
|
ok: false,
|
|
1134
1208
|
normalized: null,
|
|
1135
1209
|
errors: ['Unrecognized config format. Expected version: 3 or schema_version: "1.0" / 4'],
|
|
1210
|
+
warnings: [],
|
|
1136
1211
|
version: null,
|
|
1137
1212
|
};
|
|
1138
1213
|
}
|
|
@@ -1152,17 +1227,17 @@ export function loadNormalizedConfig(raw, projectRoot) {
|
|
|
1152
1227
|
}
|
|
1153
1228
|
}
|
|
1154
1229
|
if (errors.length > 0) {
|
|
1155
|
-
return { ok: false, normalized: null, errors, version: 3 };
|
|
1230
|
+
return { ok: false, normalized: null, errors, warnings: [], version: 3 };
|
|
1156
1231
|
}
|
|
1157
|
-
return { ok: true, normalized: normalizeV3(raw), errors: [], version: 3 };
|
|
1232
|
+
return { ok: true, normalized: normalizeV3(raw), errors: [], warnings: [], version: 3 };
|
|
1158
1233
|
}
|
|
1159
1234
|
|
|
1160
1235
|
if (version === 4) {
|
|
1161
1236
|
const validation = validateV4Config(raw, projectRoot || null);
|
|
1162
1237
|
if (!validation.ok) {
|
|
1163
|
-
return { ok: false, normalized: null, errors: validation.errors, version: 4 };
|
|
1238
|
+
return { ok: false, normalized: null, errors: validation.errors, warnings: validation.warnings || [], version: 4 };
|
|
1164
1239
|
}
|
|
1165
|
-
return { ok: true, normalized: normalizeV4(raw), errors: [], version: 4 };
|
|
1240
|
+
return { ok: true, normalized: normalizeV4(raw), errors: [], warnings: validation.warnings || [], version: 4 };
|
|
1166
1241
|
}
|
|
1167
1242
|
}
|
|
1168
1243
|
|
package/src/lib/run-loop.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* well-defined pause points. Any runner (CLI, CI, hosted, custom) composes
|
|
6
6
|
* this to implement continuous governed delivery.
|
|
7
7
|
*
|
|
8
|
+
* Supports parallel turn dispatch when max_concurrent_turns > 1 is configured
|
|
9
|
+
* for the current phase (DEC-PARALLEL-RUN-LOOP-001).
|
|
10
|
+
*
|
|
8
11
|
* Design rules:
|
|
9
12
|
* - Never calls process dot exit
|
|
10
13
|
* - No stdout/stderr
|
|
@@ -24,6 +27,8 @@ import {
|
|
|
24
27
|
approveCompletionGate,
|
|
25
28
|
getActiveTurn,
|
|
26
29
|
getActiveTurnCount,
|
|
30
|
+
getActiveTurns,
|
|
31
|
+
getMaxConcurrentTurns,
|
|
27
32
|
RUNNER_INTERFACE_VERSION,
|
|
28
33
|
} from './runner-interface.js';
|
|
29
34
|
|
|
@@ -35,6 +40,10 @@ const DEFAULT_MAX_TURNS = 50;
|
|
|
35
40
|
/**
|
|
36
41
|
* Drive governed turns to a terminal state.
|
|
37
42
|
*
|
|
43
|
+
* When max_concurrent_turns > 1 for the current phase, the loop fills
|
|
44
|
+
* available concurrency slots and dispatches all active turns concurrently.
|
|
45
|
+
* Acceptance is serialized by the existing lock mechanism.
|
|
46
|
+
*
|
|
38
47
|
* @param {string} root - project root directory
|
|
39
48
|
* @param {object} config - normalized governed config
|
|
40
49
|
* @param {object} callbacks - { selectRole, dispatch, approveGate, onEvent? }
|
|
@@ -110,107 +119,307 @@ export async function runLoop(root, config, callbacks, options = {}) {
|
|
|
110
119
|
return makeResult(false, 'max_turns_reached', state, turnsExecuted, turnHistory, gatesApproved, errors);
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
// ──
|
|
114
|
-
|
|
115
|
-
let assignState;
|
|
116
|
-
const activeTurn = getActiveTurn(state);
|
|
122
|
+
// ── Determine concurrency mode ────────────────────────────────────────
|
|
123
|
+
const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
|
|
117
124
|
|
|
118
|
-
if (
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
if (maxConcurrent <= 1) {
|
|
126
|
+
// ── Sequential mode (original behavior) ──────────────────────────
|
|
127
|
+
const seqResult = await executeSequentialTurn(root, config, state, callbacks, emit, errors);
|
|
128
|
+
if (seqResult.terminal) {
|
|
129
|
+
return makeResult(seqResult.ok, seqResult.stop_reason, loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
|
|
130
|
+
}
|
|
131
|
+
if (seqResult.accepted) {
|
|
132
|
+
turnsExecuted++;
|
|
133
|
+
}
|
|
134
|
+
turnHistory.push(...seqResult.history);
|
|
122
135
|
} else {
|
|
123
|
-
// ──
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
} catch (err) {
|
|
128
|
-
errors.push(`selectRole threw: ${err.message}`);
|
|
129
|
-
return makeResult(false, 'dispatch_error', state, turnsExecuted, turnHistory, gatesApproved, errors);
|
|
136
|
+
// ── Parallel mode ────────────────────────────────────────────────
|
|
137
|
+
const parResult = await executeParallelTurns(root, config, state, maxConcurrent, callbacks, emit, errors);
|
|
138
|
+
if (parResult.terminal) {
|
|
139
|
+
return makeResult(parResult.ok, parResult.stop_reason, loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
|
|
130
140
|
}
|
|
141
|
+
turnsExecuted += parResult.acceptedCount;
|
|
142
|
+
turnHistory.push(...parResult.history);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
131
146
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Execute a single turn (sequential mode — original behavior preserved).
|
|
149
|
+
*/
|
|
150
|
+
async function executeSequentialTurn(root, config, state, callbacks, emit, errors) {
|
|
151
|
+
let turn;
|
|
152
|
+
let assignState;
|
|
153
|
+
const activeTurn = getActiveTurn(state);
|
|
136
154
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
if (activeTurn && (activeTurn.status === 'running' || activeTurn.status === 'retrying')) {
|
|
156
|
+
turn = activeTurn;
|
|
157
|
+
assignState = state;
|
|
158
|
+
} else {
|
|
159
|
+
let roleId;
|
|
160
|
+
try {
|
|
161
|
+
roleId = callbacks.selectRole(state, config);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
errors.push(`selectRole threw: ${err.message}`);
|
|
164
|
+
return { terminal: true, ok: false, stop_reason: 'dispatch_error', history: [] };
|
|
146
165
|
}
|
|
147
166
|
|
|
148
|
-
|
|
167
|
+
if (roleId === null || roleId === undefined) {
|
|
168
|
+
emit({ type: 'caller_stopped', state });
|
|
169
|
+
return { terminal: true, ok: false, stop_reason: 'caller_stopped', history: [] };
|
|
170
|
+
}
|
|
149
171
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return makeResult(false, 'blocked', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
|
|
172
|
+
const assignResult = assignTurn(root, config, roleId);
|
|
173
|
+
if (!assignResult.ok) {
|
|
174
|
+
errors.push(`assignTurn(${roleId}): ${assignResult.error}`);
|
|
175
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history: [] };
|
|
155
176
|
}
|
|
177
|
+
turn = assignResult.turn;
|
|
178
|
+
assignState = assignResult.state;
|
|
179
|
+
emit({ type: 'turn_assigned', turn, role: roleId, state: assignState });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return await dispatchAndProcess(root, config, turn, assignState, callbacks, emit, errors);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fill concurrency slots and dispatch all active turns concurrently.
|
|
187
|
+
*/
|
|
188
|
+
async function executeParallelTurns(root, config, state, maxConcurrent, callbacks, emit, errors) {
|
|
189
|
+
const history = [];
|
|
190
|
+
let acceptedCount = 0;
|
|
191
|
+
|
|
192
|
+
// ── Collect active turns that need dispatch (retries) ────────────────
|
|
193
|
+
const activeTurns = getActiveTurns(state);
|
|
194
|
+
const turnsToDispatch = [];
|
|
195
|
+
for (const turn of Object.values(activeTurns)) {
|
|
196
|
+
if (turn.status === 'running' || turn.status === 'retrying') {
|
|
197
|
+
turnsToDispatch.push({ turn, state });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Fill concurrency slots with new assignments ──────────────────────
|
|
202
|
+
let activeCount = getActiveTurnCount(state);
|
|
203
|
+
const triedRoles = new Set();
|
|
204
|
+
while (activeCount < maxConcurrent) {
|
|
205
|
+
let roleId;
|
|
206
|
+
try {
|
|
207
|
+
roleId = callbacks.selectRole(state, config);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
errors.push(`selectRole threw: ${err.message}`);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (roleId === null || roleId === undefined) {
|
|
214
|
+
// No more roles to assign — dispatch what we have
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// If selectRole returns a role we already tried (or assigned), try
|
|
219
|
+
// other eligible roles from the routing before giving up.
|
|
220
|
+
if (triedRoles.has(roleId)) {
|
|
221
|
+
const phase = state.phase;
|
|
222
|
+
const allowed = config?.routing?.[phase]?.allowed_next_roles || [];
|
|
223
|
+
const alternateFound = allowed.some((alt) => {
|
|
224
|
+
if (alt === 'human' || triedRoles.has(alt) || !config?.roles?.[alt]) return false;
|
|
225
|
+
const altResult = assignTurn(root, config, alt);
|
|
226
|
+
if (altResult.ok) {
|
|
227
|
+
triedRoles.add(alt);
|
|
228
|
+
turnsToDispatch.push({ turn: altResult.turn, state: altResult.state });
|
|
229
|
+
emit({ type: 'turn_assigned', turn: altResult.turn, role: alt, state: altResult.state });
|
|
230
|
+
state = loadState(root, config);
|
|
231
|
+
activeCount = getActiveTurnCount(state);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
triedRoles.add(alt);
|
|
235
|
+
return false;
|
|
236
|
+
});
|
|
237
|
+
if (!alternateFound) break;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
triedRoles.add(roleId);
|
|
242
|
+
const assignResult = assignTurn(root, config, roleId);
|
|
243
|
+
if (!assignResult.ok) {
|
|
244
|
+
// Cannot assign — try other eligible roles before giving up
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
turnsToDispatch.push({ turn: assignResult.turn, state: assignResult.state });
|
|
249
|
+
emit({ type: 'turn_assigned', turn: assignResult.turn, role: roleId, state: assignResult.state });
|
|
250
|
+
|
|
251
|
+
// Reload state after assignment to get accurate active count
|
|
252
|
+
state = loadState(root, config);
|
|
253
|
+
activeCount = getActiveTurnCount(state);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Nothing to dispatch? ─────────────────────────────────────────────
|
|
257
|
+
if (turnsToDispatch.length === 0) {
|
|
258
|
+
// selectRole returned null with no active turns — caller is done
|
|
259
|
+
emit({ type: 'caller_stopped', state });
|
|
260
|
+
return { terminal: true, ok: false, stop_reason: 'caller_stopped', history: [] };
|
|
261
|
+
}
|
|
156
262
|
|
|
263
|
+
// ── Build dispatch contexts ──────────────────────────────────────────
|
|
264
|
+
const contexts = [];
|
|
265
|
+
for (const { turn, state: turnState } of turnsToDispatch) {
|
|
266
|
+
const bundleResult = writeDispatchBundle(root, turnState, config, { turnId: turn.turn_id });
|
|
267
|
+
if (!bundleResult.ok) {
|
|
268
|
+
errors.push(`writeDispatchBundle(${turn.assigned_role}): ${bundleResult.error}`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
157
271
|
const stagingPath = getTurnStagingResultPath(turn.turn_id);
|
|
158
|
-
|
|
272
|
+
contexts.push({
|
|
159
273
|
turn,
|
|
160
|
-
state:
|
|
274
|
+
state: turnState,
|
|
161
275
|
bundlePath: bundleResult.bundlePath,
|
|
162
276
|
stagingPath,
|
|
163
277
|
config,
|
|
164
278
|
root,
|
|
165
|
-
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
166
281
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
282
|
+
if (contexts.length === 0) {
|
|
283
|
+
errors.push('All dispatch bundles failed to write');
|
|
284
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history: [] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Dispatch concurrently ────────────────────────────────────────────
|
|
288
|
+
emit({ type: 'parallel_dispatch', count: contexts.length, turns: contexts.map(c => c.turn.turn_id) });
|
|
289
|
+
|
|
290
|
+
const dispatchResults = await Promise.allSettled(
|
|
291
|
+
contexts.map(async (ctx) => {
|
|
292
|
+
try {
|
|
293
|
+
return { ctx, result: await callbacks.dispatch(ctx) };
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return { ctx, result: { accept: false, reason: `dispatch threw: ${err.message}` } };
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// ── Process results sequentially (acceptance is lock-serialized) ─────
|
|
301
|
+
for (const settled of dispatchResults) {
|
|
302
|
+
const { ctx, result: dispatchResult } = settled.status === 'fulfilled'
|
|
303
|
+
? settled.value
|
|
304
|
+
: { ctx: null, result: { accept: false, reason: `Promise rejected: ${settled.reason}` } };
|
|
305
|
+
|
|
306
|
+
if (!ctx) continue;
|
|
307
|
+
|
|
308
|
+
const { turn } = ctx;
|
|
309
|
+
const roleId = turn.assigned_role;
|
|
175
310
|
|
|
176
311
|
if (dispatchResult.accept) {
|
|
177
|
-
|
|
178
|
-
const absStaging = join(root, stagingPath);
|
|
312
|
+
const absStaging = join(root, ctx.stagingPath);
|
|
179
313
|
mkdirSync(dirname(absStaging), { recursive: true });
|
|
180
314
|
writeFileSync(absStaging, JSON.stringify(dispatchResult.turnResult, null, 2));
|
|
181
315
|
|
|
182
|
-
|
|
183
|
-
const acceptResult = acceptTurn(root, config);
|
|
316
|
+
const acceptResult = acceptTurn(root, config, { turnId: turn.turn_id });
|
|
184
317
|
if (!acceptResult.ok) {
|
|
185
318
|
errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
|
|
186
|
-
|
|
187
|
-
|
|
319
|
+
// Record failure but try other turns
|
|
320
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, accept_error: acceptResult.error });
|
|
321
|
+
continue;
|
|
188
322
|
}
|
|
189
323
|
|
|
190
|
-
|
|
191
|
-
|
|
324
|
+
acceptedCount++;
|
|
325
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
|
|
192
326
|
emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
|
|
193
|
-
|
|
194
327
|
} else {
|
|
195
|
-
// Rejection
|
|
196
328
|
const validationResult = {
|
|
197
329
|
stage: 'dispatch',
|
|
198
330
|
errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
|
|
199
331
|
};
|
|
200
|
-
rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection');
|
|
201
|
-
|
|
332
|
+
rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection', { turnId: turn.turn_id });
|
|
333
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
|
|
202
334
|
emit({ type: 'turn_rejected', turn, role: roleId, reason: dispatchResult.reason });
|
|
203
335
|
|
|
204
|
-
// Check if
|
|
336
|
+
// Check if rejection blocked the run
|
|
205
337
|
const postRejectState = loadState(root, config);
|
|
206
338
|
if (postRejectState?.status === 'blocked') {
|
|
207
339
|
errors.push(`Turn rejected for ${roleId}, retries exhausted`);
|
|
208
340
|
emit({ type: 'blocked', state: postRejectState });
|
|
209
|
-
return
|
|
341
|
+
return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history, acceptedCount };
|
|
210
342
|
}
|
|
211
|
-
// Otherwise continue — loop will detect the active turn and re-dispatch
|
|
212
343
|
}
|
|
213
344
|
}
|
|
345
|
+
|
|
346
|
+
// ── Stall detection: if no turns were accepted and no new roles were ──
|
|
347
|
+
// ── assignable, terminate to avoid infinite re-dispatch loops. ────────
|
|
348
|
+
if (acceptedCount === 0 && history.length > 0) {
|
|
349
|
+
const allFailed = history.every(h => !h.accepted);
|
|
350
|
+
if (allFailed) {
|
|
351
|
+
errors.push('All parallel turns failed acceptance — stalled');
|
|
352
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { terminal: false, history, acceptedCount };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Dispatch a single turn and process its result.
|
|
361
|
+
*/
|
|
362
|
+
async function dispatchAndProcess(root, config, turn, assignState, callbacks, emit, errors) {
|
|
363
|
+
const roleId = turn.assigned_role;
|
|
364
|
+
const history = [];
|
|
365
|
+
|
|
366
|
+
const bundleResult = writeDispatchBundle(root, assignState, config);
|
|
367
|
+
if (!bundleResult.ok) {
|
|
368
|
+
errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
|
|
369
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const stagingPath = getTurnStagingResultPath(turn.turn_id);
|
|
373
|
+
const context = {
|
|
374
|
+
turn,
|
|
375
|
+
state: assignState,
|
|
376
|
+
bundlePath: bundleResult.bundlePath,
|
|
377
|
+
stagingPath,
|
|
378
|
+
config,
|
|
379
|
+
root,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
let dispatchResult;
|
|
383
|
+
try {
|
|
384
|
+
dispatchResult = await callbacks.dispatch(context);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
errors.push(`dispatch threw for ${roleId}: ${err.message}`);
|
|
387
|
+
return { terminal: true, ok: false, stop_reason: 'dispatch_error', history };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (dispatchResult.accept) {
|
|
391
|
+
const absStaging = join(root, stagingPath);
|
|
392
|
+
mkdirSync(dirname(absStaging), { recursive: true });
|
|
393
|
+
writeFileSync(absStaging, JSON.stringify(dispatchResult.turnResult, null, 2));
|
|
394
|
+
|
|
395
|
+
const acceptResult = acceptTurn(root, config);
|
|
396
|
+
if (!acceptResult.ok) {
|
|
397
|
+
errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
|
|
398
|
+
return { terminal: true, ok: false, stop_reason: 'blocked', history };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
|
|
402
|
+
emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
|
|
403
|
+
return { terminal: false, accepted: true, history };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Rejection
|
|
407
|
+
const validationResult = {
|
|
408
|
+
stage: 'dispatch',
|
|
409
|
+
errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
|
|
410
|
+
};
|
|
411
|
+
rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection');
|
|
412
|
+
history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
|
|
413
|
+
emit({ type: 'turn_rejected', turn, role: roleId, reason: dispatchResult.reason });
|
|
414
|
+
|
|
415
|
+
const postRejectState = loadState(root, config);
|
|
416
|
+
if (postRejectState?.status === 'blocked') {
|
|
417
|
+
errors.push(`Turn rejected for ${roleId}, retries exhausted`);
|
|
418
|
+
emit({ type: 'blocked', state: postRejectState });
|
|
419
|
+
return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { terminal: false, accepted: false, history };
|
|
214
423
|
}
|
|
215
424
|
|
|
216
425
|
/**
|
package/src/lib/validation.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateAcceptanceHintCompletion,
|
|
10
10
|
validateGovernedWorkflowKit,
|
|
11
11
|
} from './governed-templates.js';
|
|
12
|
+
import { collectRemoteReviewOnlyGateWarnings } from './normalized-config.js';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_REQUIRED_FILES = [
|
|
14
15
|
'.planning/PROJECT.md',
|
|
@@ -115,6 +116,9 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
|
|
|
115
116
|
errors.push(...workflowKit.errors);
|
|
116
117
|
warnings.push(...workflowKit.warnings);
|
|
117
118
|
|
|
119
|
+
// Config-shape warnings (dead-end gates, etc.) — mirrors doctor/config --set surfaces
|
|
120
|
+
warnings.push(...collectRemoteReviewOnlyGateWarnings(rawConfig));
|
|
121
|
+
|
|
118
122
|
const mustExist = [
|
|
119
123
|
config.files?.state || '.agentxchain/state.json',
|
|
120
124
|
config.files?.history || '.agentxchain/history.jsonl',
|