agentxchain 2.84.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.
@@ -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 from this repo:
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 from this repo:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.84.0",
3
+ "version": "2.85.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -270,6 +270,7 @@ export async function executeGovernedRun(context, opts = {}) {
270
270
  signal: controller.signal,
271
271
  onStatus: (msg) => log(chalk.dim(` ${msg}`)),
272
272
  verifyManifest: true,
273
+ turnId: turn.turn_id,
273
274
  };
274
275
 
275
276
  if (verbose) {
@@ -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
- // ── Check for active turn (retry after rejection) ──────────────────��
114
- let turn;
115
- let assignState;
116
- const activeTurn = getActiveTurn(state);
122
+ // ── Determine concurrency mode ────────────────────────────────────────
123
+ const maxConcurrent = getMaxConcurrentTurns(config, state.phase);
117
124
 
118
- if (activeTurn && (activeTurn.status === 'running' || activeTurn.status === 'retrying')) {
119
- // Re-dispatch an existing active turn (retry after rejection)
120
- turn = activeTurn;
121
- assignState = state;
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
- // ── Role selection ────────────────────────────────────────────────
124
- let roleId;
125
- try {
126
- roleId = callbacks.selectRole(state, config);
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
- if (roleId === null || roleId === undefined) {
133
- emit({ type: 'caller_stopped', state });
134
- return makeResult(false, 'caller_stopped', state, turnsExecuted, turnHistory, gatesApproved, errors);
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
- // ── Turn assignment ───────────────────────────────────────────────
138
- const assignResult = assignTurn(root, config, roleId);
139
- if (!assignResult.ok) {
140
- errors.push(`assignTurn(${roleId}): ${assignResult.error}`);
141
- return makeResult(false, 'blocked', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
142
- }
143
- turn = assignResult.turn;
144
- assignState = assignResult.state;
145
- emit({ type: 'turn_assigned', turn, role: roleId, state: assignState });
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
- const roleId = turn.assigned_role;
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
- // ── Dispatch bundle ─────────────────────────────────────────────────
151
- const bundleResult = writeDispatchBundle(root, assignState, config);
152
- if (!bundleResult.ok) {
153
- errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
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
- const context = {
272
+ contexts.push({
159
273
  turn,
160
- state: assignState,
274
+ state: turnState,
161
275
  bundlePath: bundleResult.bundlePath,
162
276
  stagingPath,
163
277
  config,
164
278
  root,
165
- };
279
+ });
280
+ }
166
281
 
167
- // ── Dispatch ────────────────────────────────────────────────────────
168
- let dispatchResult;
169
- try {
170
- dispatchResult = await callbacks.dispatch(context);
171
- } catch (err) {
172
- errors.push(`dispatch threw for ${roleId}: ${err.message}`);
173
- return makeResult(false, 'dispatch_error', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
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
- // Stage the turn result
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
- // Accept
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
- const postState = loadState(root, config);
187
- return makeResult(false, 'blocked', postState, turnsExecuted, turnHistory, gatesApproved, errors);
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
- turnsExecuted++;
191
- turnHistory.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
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
- turnHistory.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
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 retries exhausted run blocked
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 makeResult(false, 'reject_exhausted', postRejectState, turnsExecuted, turnHistory, gatesApproved, errors);
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
  /**