agentxchain 2.146.0 → 2.148.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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/scripts/publish-npm.sh +16 -0
  3. package/scripts/sync-homebrew.sh +14 -1
  4. package/scripts/verify-post-publish.sh +55 -4
  5. package/src/commands/reissue-turn.js +16 -0
  6. package/src/commands/reject-turn.js +14 -1
  7. package/src/commands/restart.js +15 -0
  8. package/src/commands/resume.js +61 -66
  9. package/src/commands/run.js +67 -10
  10. package/src/commands/schedule.js +34 -7
  11. package/src/commands/status.js +20 -0
  12. package/src/commands/step.js +100 -34
  13. package/src/lib/adapters/api-proxy-adapter.js +8 -0
  14. package/src/lib/adapters/local-cli-adapter.js +271 -16
  15. package/src/lib/adapters/manual-adapter.js +9 -10
  16. package/src/lib/adapters/mcp-adapter.js +3 -5
  17. package/src/lib/adapters/remote-agent-adapter.js +3 -5
  18. package/src/lib/continuous-run.js +71 -6
  19. package/src/lib/dispatch-bundle.js +1 -1
  20. package/src/lib/dispatch-progress.js +5 -3
  21. package/src/lib/governed-state.js +258 -17
  22. package/src/lib/intake.js +10 -1
  23. package/src/lib/normalized-config.js +51 -1
  24. package/src/lib/recent-event-summary.js +11 -0
  25. package/src/lib/run-events.js +4 -0
  26. package/src/lib/run-loop.js +67 -2
  27. package/src/lib/runner-interface.js +1 -0
  28. package/src/lib/schema.js +7 -0
  29. package/src/lib/schemas/agentxchain-config.schema.json +15 -1
  30. package/src/lib/schemas/turn-result.schema.json +8 -2
  31. package/src/lib/staged-result-proof.js +43 -0
  32. package/src/lib/stale-turn-watchdog.js +218 -90
  33. package/src/lib/turn-checkpoint.js +65 -1
  34. package/src/lib/turn-result-shape.js +38 -0
  35. package/src/lib/turn-result-validator.js +15 -3
@@ -395,6 +395,11 @@ export function validateV4Config(data, projectRoot) {
395
395
  } else {
396
396
  if (typeof data.project.id !== 'string' || !data.project.id.trim()) errors.push('project.id must be a non-empty string');
397
397
  if (typeof data.project.name !== 'string' || !data.project.name.trim()) errors.push('project.name must be a non-empty string');
398
+ if ('default_branch' in data.project) {
399
+ if (typeof data.project.default_branch !== 'string' || !data.project.default_branch.trim()) {
400
+ errors.push('project.default_branch must be a non-empty string when provided');
401
+ }
402
+ }
398
403
  // Optional project.goal field
399
404
  if (data.project.goal !== undefined && data.project.goal !== null) {
400
405
  if (typeof data.project.goal !== 'string') {
@@ -480,6 +485,17 @@ export function validateV4Config(data, projectRoot) {
480
485
  }
481
486
  }
482
487
  }
488
+ // Schema publishes max_output_tokens as `integer, minimum: 1`. The
489
+ // api-proxy adapter silently falls back to 4096 on `0` / null /
490
+ // undefined and passes negative/non-integer values straight through
491
+ // to the provider, which is the same silent-fallback defect class
492
+ // the run_loop watchdog knobs had (DEC-SILENT-FALLBACK-DEFECT-CLASS-001).
493
+ // Reject at write time so the operator sees the bad value immediately.
494
+ if ('max_output_tokens' in rt) {
495
+ if (!Number.isInteger(rt.max_output_tokens) || rt.max_output_tokens < 1) {
496
+ errors.push(`Runtime "${id}": max_output_tokens must be a positive integer`);
497
+ }
498
+ }
483
499
  if ('retry_policy' in rt) {
484
500
  validateApiProxyRetryPolicy(id, rt.retry_policy, errors);
485
501
  }
@@ -597,6 +613,14 @@ export function validateV4Config(data, projectRoot) {
597
613
  errors.push(...timeoutValidation.errors);
598
614
  }
599
615
 
616
+ // Run-loop watchdog knobs (BUG-47 / BUG-51). Schema publishes both as
617
+ // positive integers; runtime silently falls back to defaults on bad input,
618
+ // which misleads operators into thinking their config --set took effect.
619
+ // Reject at config-write / validate time so the operator sees the problem.
620
+ if (data.run_loop !== undefined) {
621
+ errors.push(...validateRunLoopConfig(data.run_loop));
622
+ }
623
+
600
624
  // Admission control (ADM-001..004) is handled by the validate, doctor, and
601
625
  // run-loop paths which call runAdmissionControl() directly. Config schema
602
626
  // validation here should not duplicate that surface.
@@ -604,6 +628,30 @@ export function validateV4Config(data, projectRoot) {
604
628
  return { ok: errors.length === 0, errors, warnings };
605
629
  }
606
630
 
631
+ export function validateRunLoopConfig(runLoop) {
632
+ const errors = [];
633
+ if (runLoop === null || typeof runLoop !== 'object' || Array.isArray(runLoop)) {
634
+ errors.push('run_loop must be an object');
635
+ return errors;
636
+ }
637
+ validateRunLoopPositiveInteger('run_loop.startup_watchdog_ms', runLoop.startup_watchdog_ms, errors);
638
+ validateRunLoopPositiveInteger('run_loop.stale_turn_threshold_ms', runLoop.stale_turn_threshold_ms, errors);
639
+ return errors;
640
+ }
641
+
642
+ function validateRunLoopPositiveInteger(path, value, errors) {
643
+ if (value === undefined || value === null) {
644
+ return;
645
+ }
646
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
647
+ errors.push(`${path} must be a positive integer (milliseconds)`);
648
+ return;
649
+ }
650
+ if (value < 1) {
651
+ errors.push(`${path} must be a positive integer (milliseconds)`);
652
+ }
653
+ }
654
+
607
655
  export function validateBudgetConfig(budget) {
608
656
  const errors = [];
609
657
 
@@ -1145,7 +1193,9 @@ export function normalizeV4(raw) {
1145
1193
  id: raw.project?.id || 'unknown',
1146
1194
  name: raw.project?.name || 'Unknown',
1147
1195
  ...(typeof raw.project?.goal === 'string' && raw.project.goal.trim() ? { goal: raw.project.goal.trim() } : {}),
1148
- default_branch: raw.project?.default_branch || 'main',
1196
+ default_branch: typeof raw.project?.default_branch === 'string' && raw.project.default_branch.trim()
1197
+ ? raw.project.default_branch.trim()
1198
+ : 'main',
1149
1199
  },
1150
1200
  roles,
1151
1201
  runtimes: raw.runtimes || {},
@@ -55,6 +55,8 @@ function describeEvent(eventType, entry) {
55
55
  case 'turn_checkpointed':
56
56
  case 'turn_stalled':
57
57
  case 'turn_start_failed':
58
+ case 'runtime_spawn_failed':
59
+ case 'stdout_attach_failed':
58
60
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
59
61
  case 'dispatch_progress':
60
62
  return `${prefix}${eventType}${roleId ? ` [${roleId}]` : ''}`;
@@ -77,6 +79,15 @@ function describeEvent(eventType, entry) {
77
79
  case 'escalation_resolved':
78
80
  case 'budget_exceeded_warn':
79
81
  return `${prefix}${eventType}`;
82
+ case 'session_continuation': {
83
+ const prev = trimToNull(entry.payload?.previous_run_id);
84
+ const next = trimToNull(entry.payload?.next_run_id);
85
+ const objective = trimToNull(entry.payload?.next_objective);
86
+ if (prev && next) {
87
+ return `${prefix}${eventType} ${prev} -> ${next}${objective ? ` (${objective})` : ''}`;
88
+ }
89
+ return `${prefix}${eventType}`;
90
+ }
80
91
  default:
81
92
  if (trimToNull(entry.summary)) return entry.summary.trim();
82
93
  return `${prefix}${eventType || 'unknown_event'}`;
@@ -25,6 +25,9 @@ export const VALID_RUN_EVENTS = [
25
25
  'acceptance_failed',
26
26
  'turn_reissued',
27
27
  'turn_stalled',
28
+ 'turn_start_failed',
29
+ 'runtime_spawn_failed',
30
+ 'stdout_attach_failed',
28
31
  'turn_checkpointed',
29
32
  'coordinator_retry',
30
33
  'coordinator_retry_projection_warning',
@@ -39,6 +42,7 @@ export const VALID_RUN_EVENTS = [
39
42
  'human_escalation_raised',
40
43
  'human_escalation_resolved',
41
44
  'dispatch_progress',
45
+ 'session_continuation',
42
46
  ];
43
47
 
44
48
  /**
@@ -31,6 +31,7 @@ import {
31
31
  getActiveTurnCount,
32
32
  getActiveTurns,
33
33
  getMaxConcurrentTurns,
34
+ transitionActiveTurnLifecycle,
34
35
  RUNNER_INTERFACE_VERSION,
35
36
  } from './runner-interface.js';
36
37
 
@@ -40,6 +41,18 @@ import { join, dirname } from 'path';
40
41
  import { evaluateApprovalSlaReminders } from './notification-runner.js';
41
42
  import { validatePreemptionMarker } from './intake.js';
42
43
  import { buildTimeoutBlockedReason, evaluateTimeouts } from './timeout-evaluator.js';
44
+ import { hasMinimumTurnResultShape } from './turn-result-shape.js';
45
+
46
+ // Per DEC-RUN-LOOP-MIN-SHAPE-SYMMETRY-001 (Turn 33): runLoop is the SDK boundary
47
+ // any third-party runner can wire (see website-v2/docs/build-your-own-runner.mdx).
48
+ // In-repo adapters (api_proxy, mcp, local_cli, remote_agent) already validate
49
+ // staged-result shape before write per DEC-MINIMUM-TURN-RESULT-SHAPE-001, and
50
+ // run.js's dispatch callback re-validates before returning per
51
+ // DEC-RUN-STAGED-READ-SHAPE-GUARD-001. Third-party callbacks have no such
52
+ // obligation. runLoop must therefore validate dispatchResult.turnResult shape
53
+ // before persisting it as a governed staged-result artifact.
54
+ const MIN_SHAPE_REJECTION_REASON =
55
+ 'staged result missing minimum governed envelope (schema_version + identity + lifecycle fields)';
43
56
 
44
57
  const DEFAULT_MAX_TURNS = 50;
45
58
 
@@ -182,7 +195,7 @@ async function executeSequentialTurn(root, config, state, callbacks, emit, error
182
195
  let assignState;
183
196
  const activeTurn = getActiveTurn(state);
184
197
 
185
- if (activeTurn && (activeTurn.status === 'running' || activeTurn.status === 'retrying')) {
198
+ if (activeTurn && isDispatchableActiveTurn(activeTurn)) {
186
199
  turn = activeTurn;
187
200
  assignState = state;
188
201
  } else {
@@ -224,7 +237,7 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
224
237
  const activeTurns = getActiveTurns(state);
225
238
  const turnsToDispatch = [];
226
239
  for (const turn of Object.values(activeTurns)) {
227
- if (turn.status === 'running' || turn.status === 'retrying') {
240
+ if (isDispatchableActiveTurn(turn)) {
228
241
  turnsToDispatch.push({ turn, state });
229
242
  }
230
243
  }
@@ -317,6 +330,7 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
317
330
  errors.push(`writeDispatchBundle(${turn.assigned_role}): ${bundleResult.error}`);
318
331
  continue;
319
332
  }
333
+ transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
320
334
  const stagingPath = getTurnStagingResultPath(turn.turn_id);
321
335
  contexts.push({
322
336
  turn,
@@ -362,6 +376,23 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
362
376
  continue;
363
377
  }
364
378
 
379
+ if (dispatchResult.accept && !hasMinimumTurnResultShape(dispatchResult.turnResult)) {
380
+ // DEC-RUN-LOOP-MIN-SHAPE-SYMMETRY-001: third-party dispatch callback claimed
381
+ // accept=true but returned a payload missing the minimum envelope. Refuse to
382
+ // stage; convert to standard rejection so the run state advances cleanly.
383
+ const validationResult = { stage: 'dispatch', errors: [MIN_SHAPE_REJECTION_REASON] };
384
+ rejectTurn(root, config, validationResult, MIN_SHAPE_REJECTION_REASON, { turnId: turn.turn_id });
385
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
386
+ emit({ type: 'turn_rejected', turn, role: roleId, reason: MIN_SHAPE_REJECTION_REASON });
387
+ const postRejectState = loadState(root, config);
388
+ if (postRejectState?.status === 'blocked') {
389
+ errors.push(`Turn rejected for ${roleId}, retries exhausted`);
390
+ emit({ type: 'blocked', state: postRejectState });
391
+ return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history, acceptedCount };
392
+ }
393
+ continue;
394
+ }
395
+
365
396
  if (dispatchResult.accept) {
366
397
  const absStaging = join(root, ctx.stagingPath);
367
398
  mkdirSync(dirname(absStaging), { recursive: true });
@@ -409,6 +440,12 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
409
440
  }
410
441
  emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
411
442
  } else {
443
+ if (dispatchResult?.blocked === true) {
444
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, blocked: true });
445
+ const blockedState = loadState(root, config);
446
+ emit({ type: 'blocked', state: blockedState });
447
+ return { terminal: true, ok: false, stop_reason: 'blocked', history, acceptedCount };
448
+ }
412
449
  const validationResult = {
413
450
  stage: 'dispatch',
414
451
  errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
@@ -449,6 +486,10 @@ async function executeParallelTurns(root, config, state, maxConcurrent, callback
449
486
  return { terminal: false, history, acceptedCount };
450
487
  }
451
488
 
489
+ function isDispatchableActiveTurn(turn) {
490
+ return ['assigned', 'dispatched', 'starting', 'running', 'retrying'].includes(turn?.status);
491
+ }
492
+
452
493
  /**
453
494
  * Dispatch a single turn and process its result.
454
495
  */
@@ -463,6 +504,7 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
463
504
  errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
464
505
  return { terminal: true, ok: false, stop_reason: 'blocked', history };
465
506
  }
507
+ transitionActiveTurnLifecycle(root, turn.turn_id, 'dispatched');
466
508
 
467
509
  const stagingPath = getTurnStagingResultPath(turn.turn_id);
468
510
  const context = {
@@ -488,6 +530,22 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
488
530
  return { terminal: true, ok: false, stop_reason: 'blocked', history };
489
531
  }
490
532
 
533
+ if (dispatchResult.accept && !hasMinimumTurnResultShape(dispatchResult.turnResult)) {
534
+ // DEC-RUN-LOOP-MIN-SHAPE-SYMMETRY-001: same boundary as parallel branch.
535
+ // Refuse to stage; convert to a standard rejection.
536
+ const validationResult = { stage: 'dispatch', errors: [MIN_SHAPE_REJECTION_REASON] };
537
+ rejectTurn(root, config, validationResult, MIN_SHAPE_REJECTION_REASON);
538
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
539
+ emit({ type: 'turn_rejected', turn, role: roleId, reason: MIN_SHAPE_REJECTION_REASON });
540
+ const postRejectState = loadState(root, config);
541
+ if (postRejectState?.status === 'blocked') {
542
+ errors.push(`Turn rejected for ${roleId}, retries exhausted`);
543
+ emit({ type: 'blocked', state: postRejectState });
544
+ return { terminal: true, ok: false, stop_reason: 'reject_exhausted', history };
545
+ }
546
+ return { terminal: false, accepted: false, history };
547
+ }
548
+
491
549
  if (dispatchResult.accept) {
492
550
  const absStaging = join(root, stagingPath);
493
551
  mkdirSync(dirname(absStaging), { recursive: true });
@@ -537,6 +595,13 @@ async function dispatchAndProcess(root, config, turn, assignState, callbacks, em
537
595
  return { terminal: false, accepted: true, history };
538
596
  }
539
597
 
598
+ if (dispatchResult?.blocked === true) {
599
+ history.push({ role: roleId, turn_id: turn.turn_id, accepted: false, blocked: true });
600
+ const blockedState = loadState(root, config);
601
+ emit({ type: 'blocked', state: blockedState });
602
+ return { terminal: true, ok: false, stop_reason: 'blocked', history };
603
+ }
604
+
540
605
  // Rejection
541
606
  const validationResult = {
542
607
  stage: 'dispatch',
@@ -41,6 +41,7 @@ export {
41
41
  releaseAcceptanceLock as releaseLock,
42
42
  refreshTurnBaselineSnapshot,
43
43
  reissueTurn,
44
+ transitionActiveTurnLifecycle,
44
45
  } from './governed-state.js';
45
46
 
46
47
  // ── Dispatch ────────────────────────────────────────────────────────────────
package/src/lib/schema.js CHANGED
@@ -35,6 +35,13 @@ export function validateGovernedStateSchema(data) {
35
35
  // but validators and read-only surfaces still tolerate reserved/manual states.
36
36
  const VALID_RUN_STATUSES = ['idle', 'active', 'paused', 'blocked', 'completed', 'failed'];
37
37
  const isV1_1 = data?.schema_version === '1.1';
38
+ // NOTE: `current_turn` is the persisted v1.0 schema field. Under v1.1 it is
39
+ // not a persisted field at all — `loadProjectState()` re-attaches it as a
40
+ // non-enumerable getter alias over `active_turns` after normalization
41
+ // (DEC-CURRENT-TURN-COMPAT-ALIAS-001). This validator runs against the
42
+ // persisted shape, so an `own` property named `current_turn` on a v1.1 doc
43
+ // means "stray persisted-shape leak from a legacy write" and is rejected
44
+ // below — it does NOT mean the runtime alias is going away.
38
45
  const hasLegacyCurrentTurn = Object.prototype.hasOwnProperty.call(data || {}, 'current_turn');
39
46
 
40
47
  function validateTurn(turn, label) {
@@ -85,7 +85,21 @@
85
85
  "type": "object"
86
86
  },
87
87
  "run_loop": {
88
- "type": "object"
88
+ "type": "object",
89
+ "description": "Runner control knobs for execution watchdogs and automation behavior.",
90
+ "properties": {
91
+ "startup_watchdog_ms": {
92
+ "type": "integer",
93
+ "minimum": 1,
94
+ "description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default 30000."
95
+ },
96
+ "stale_turn_threshold_ms": {
97
+ "type": "integer",
98
+ "minimum": 1,
99
+ "description": "Milliseconds to wait before a started turn that previously produced output is treated as stale. Default 600000 for local_cli turns and 300000 for api_proxy turns."
100
+ }
101
+ },
102
+ "additionalProperties": true
89
103
  },
90
104
  "mission_planner": {
91
105
  "type": "object"
@@ -148,7 +148,10 @@
148
148
  },
149
149
  "commands": {
150
150
  "type": "array",
151
- "items": { "type": "string" },
151
+ "items": {
152
+ "type": "string",
153
+ "pattern": "\\S"
154
+ },
152
155
  "description": "Verification commands that were run."
153
156
  },
154
157
  "evidence_summary": {
@@ -161,7 +164,10 @@
161
164
  "type": "object",
162
165
  "required": ["command", "exit_code"],
163
166
  "properties": {
164
- "command": { "type": "string" },
167
+ "command": {
168
+ "type": "string",
169
+ "pattern": "\\S"
170
+ },
165
171
  "exit_code": { "type": "integer" }
166
172
  }
167
173
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Staged turn-result proof helpers.
3
+ *
4
+ * Per DEC-BUG51-STAGING-PLACEHOLDER-NOT-PROOF-001: a turn-scoped staged-result
5
+ * file is proof of execution only when it contains meaningful result content.
6
+ * Adapter-authored placeholders (`{}`, blank, whitespace-only) are cleanup
7
+ * artifacts — watchdog, adapter, and recovery code must treat them as absent.
8
+ *
9
+ * This module centralizes that check so every surface (local-cli adapter,
10
+ * manual adapter, stale-turn watchdog) uses the same rule.
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+
15
+ /**
16
+ * Returns true when the staged-result file at `filePath` exists AND contains
17
+ * content that is not a placeholder (empty, whitespace-only, or `{}`).
18
+ *
19
+ * Trim-aware: `{}\n`, ` {}\n`, and `{}` are all rejected. Legitimate turn
20
+ * results carry the full governed schema and are far larger than the
21
+ * placeholder shapes this function filters.
22
+ *
23
+ * @param {string} filePath - absolute path to the staged-result file
24
+ * @returns {boolean}
25
+ */
26
+ export function hasMeaningfulStagedResult(filePath) {
27
+ if (!existsSync(filePath)) {
28
+ return false;
29
+ }
30
+
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(filePath, 'utf8');
34
+ } catch {
35
+ return false;
36
+ }
37
+
38
+ const trimmed = raw.trim();
39
+ if (trimmed === '' || trimmed === '{}') {
40
+ return false;
41
+ }
42
+ return true;
43
+ }