agentxchain 2.46.0 → 2.46.2

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.
@@ -15,6 +15,7 @@
15
15
  import { validateHooksConfig } from './hook-runner.js';
16
16
  import { validateNotificationsConfig } from './notification-runner.js';
17
17
  import { validatePolicies, normalizePolicies } from './policy-evaluator.js';
18
+ import { validateTimeoutsConfig } from './timeout-evaluator.js';
18
19
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
19
20
  import {
20
21
  buildDefaultWorkflowKitArtifactsForPhase,
@@ -519,6 +520,17 @@ export function validateV4Config(data, projectRoot) {
519
520
  errors.push(...policyValidation.errors);
520
521
  }
521
522
 
523
+ // Approval Policy (optional but validated if present)
524
+ if (data.approval_policy !== undefined) {
525
+ errors.push(...validateApprovalPolicy(data.approval_policy, data.routing));
526
+ }
527
+
528
+ // Timeouts (optional but validated if present)
529
+ if (data.timeouts !== undefined) {
530
+ const timeoutValidation = validateTimeoutsConfig(data.timeouts, data.routing);
531
+ errors.push(...timeoutValidation.errors);
532
+ }
533
+
522
534
  return { ok: errors.length === 0, errors };
523
535
  }
524
536
 
@@ -691,6 +703,113 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
691
703
  return { ok: errors.length === 0, errors, warnings };
692
704
  }
693
705
 
706
+ const VALID_APPROVAL_ACTIONS = ['auto_approve', 'require_human'];
707
+
708
+ /**
709
+ * Validate the approval_policy config section.
710
+ * Returns an array of error strings.
711
+ */
712
+ export function validateApprovalPolicy(ap, routing) {
713
+ const errors = [];
714
+ if (ap === null || ap === undefined) return errors;
715
+ if (typeof ap !== 'object' || Array.isArray(ap)) {
716
+ errors.push('approval_policy must be an object');
717
+ return errors;
718
+ }
719
+
720
+ const routingPhases = routing ? Object.keys(routing) : [];
721
+
722
+ // phase_transitions
723
+ if (ap.phase_transitions !== undefined) {
724
+ const pt = ap.phase_transitions;
725
+ if (typeof pt !== 'object' || Array.isArray(pt)) {
726
+ errors.push('approval_policy.phase_transitions must be an object');
727
+ } else {
728
+ if (pt.default !== undefined && !VALID_APPROVAL_ACTIONS.includes(pt.default)) {
729
+ errors.push(`approval_policy.phase_transitions.default must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
730
+ }
731
+ if (pt.rules !== undefined) {
732
+ if (!Array.isArray(pt.rules)) {
733
+ errors.push('approval_policy.phase_transitions.rules must be an array');
734
+ } else {
735
+ for (let i = 0; i < pt.rules.length; i++) {
736
+ const rule = pt.rules[i];
737
+ const prefix = `approval_policy.phase_transitions.rules[${i}]`;
738
+ if (!rule || typeof rule !== 'object') {
739
+ errors.push(`${prefix} must be an object`);
740
+ continue;
741
+ }
742
+ if (!VALID_APPROVAL_ACTIONS.includes(rule.action)) {
743
+ errors.push(`${prefix}.action must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
744
+ }
745
+ if (rule.from_phase !== undefined) {
746
+ if (typeof rule.from_phase !== 'string') {
747
+ errors.push(`${prefix}.from_phase must be a string`);
748
+ } else if (routingPhases.length > 0 && !routingPhases.includes(rule.from_phase)) {
749
+ errors.push(`${prefix}.from_phase "${rule.from_phase}" does not exist in routing`);
750
+ }
751
+ }
752
+ if (rule.to_phase !== undefined) {
753
+ if (typeof rule.to_phase !== 'string') {
754
+ errors.push(`${prefix}.to_phase must be a string`);
755
+ } else if (routingPhases.length > 0 && !routingPhases.includes(rule.to_phase)) {
756
+ errors.push(`${prefix}.to_phase "${rule.to_phase}" does not exist in routing`);
757
+ }
758
+ }
759
+ if (rule.when !== undefined) {
760
+ errors.push(...validateApprovalWhen(rule.when, prefix));
761
+ }
762
+ }
763
+ }
764
+ }
765
+ }
766
+ }
767
+
768
+ // run_completion
769
+ if (ap.run_completion !== undefined) {
770
+ const rc = ap.run_completion;
771
+ if (typeof rc !== 'object' || Array.isArray(rc)) {
772
+ errors.push('approval_policy.run_completion must be an object');
773
+ } else {
774
+ if (rc.action !== undefined && !VALID_APPROVAL_ACTIONS.includes(rc.action)) {
775
+ errors.push(`approval_policy.run_completion.action must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
776
+ }
777
+ if (rc.when !== undefined) {
778
+ errors.push(...validateApprovalWhen(rc.when, 'approval_policy.run_completion'));
779
+ }
780
+ }
781
+ }
782
+
783
+ return errors;
784
+ }
785
+
786
+ function validateApprovalWhen(when, prefix) {
787
+ const errors = [];
788
+ if (typeof when !== 'object' || Array.isArray(when) || when === null) {
789
+ errors.push(`${prefix}.when must be an object`);
790
+ return errors;
791
+ }
792
+ if (when.gate_passed !== undefined && typeof when.gate_passed !== 'boolean') {
793
+ errors.push(`${prefix}.when.gate_passed must be a boolean`);
794
+ }
795
+ if (when.roles_participated !== undefined) {
796
+ if (!Array.isArray(when.roles_participated)) {
797
+ errors.push(`${prefix}.when.roles_participated must be an array of role IDs`);
798
+ } else {
799
+ for (const r of when.roles_participated) {
800
+ if (typeof r !== 'string' || !r.trim()) {
801
+ errors.push(`${prefix}.when.roles_participated entries must be non-empty strings`);
802
+ break;
803
+ }
804
+ }
805
+ }
806
+ }
807
+ if (when.all_phases_visited !== undefined && typeof when.all_phases_visited !== 'boolean') {
808
+ errors.push(`${prefix}.when.all_phases_visited must be a boolean`);
809
+ }
810
+ return errors;
811
+ }
812
+
694
813
  /**
695
814
  * Normalize a legacy v3 config into the internal shape.
696
815
  * Does NOT modify the original file — this is a read-time transformation.
@@ -733,6 +852,8 @@ export function normalizeV3(raw) {
733
852
  notifications: {},
734
853
  budget: null,
735
854
  policies: [],
855
+ approval_policy: null,
856
+ timeouts: null,
736
857
  workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
737
858
  retention: {
738
859
  talk_strategy: 'append_only',
@@ -798,6 +919,8 @@ export function normalizeV4(raw) {
798
919
  notifications: raw.notifications || {},
799
920
  budget: raw.budget || null,
800
921
  policies: normalizePolicies(raw.policies),
922
+ approval_policy: raw.approval_policy || null,
923
+ timeouts: raw.timeouts || null,
801
924
  workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
802
925
  retention: raw.retention || {
803
926
  talk_strategy: 'append_only',
@@ -139,6 +139,7 @@ function inflateState(rawState = {}, config) {
139
139
  blocked_on: rawState.blocked_on ?? null,
140
140
  blocked_reason: rawState.blocked_reason ?? null,
141
141
  escalation: rawState.escalation ?? null,
142
+ last_gate_failure: rawState.last_gate_failure ?? null,
142
143
  accepted_sequence: rawState.accepted_sequence ?? 0,
143
144
  turn_sequence: rawState.turn_sequence ?? 0,
144
145
  budget_reservations: rawState.budget_reservations ?? {},
@@ -121,6 +121,7 @@ export function observeChanges(root, baseline) {
121
121
  // Non-git project — no observation possible
122
122
  return {
123
123
  files_changed: [],
124
+ file_markers: {},
124
125
  head_ref: null,
125
126
  diff_summary: null,
126
127
  observation_available: false,
@@ -162,6 +163,7 @@ export function observeChanges(root, baseline) {
162
163
 
163
164
  return {
164
165
  files_changed: actorFiles.sort(),
166
+ file_markers: buildFileMarkers(root, actorFiles),
165
167
  head_ref: currentHead,
166
168
  diff_summary: diffSummary,
167
169
  observation_available: true,
@@ -169,6 +171,119 @@ export function observeChanges(root, baseline) {
169
171
  };
170
172
  }
171
173
 
174
+ export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = []) {
175
+ const observedFiles = Array.isArray(observation?.files_changed) ? observation.files_changed : [];
176
+ if (observedFiles.length === 0) {
177
+ return observation;
178
+ }
179
+
180
+ const concurrentIds = new Set(
181
+ Array.isArray(currentTurn?.concurrent_with) ? currentTurn.concurrent_with : [],
182
+ );
183
+ if (concurrentIds.size === 0) {
184
+ return observation;
185
+ }
186
+
187
+ const assignedSequence = Number.isInteger(currentTurn?.assigned_sequence)
188
+ ? currentTurn.assigned_sequence
189
+ : 0;
190
+ const acceptedConcurrentSiblings = (Array.isArray(historyEntries) ? historyEntries : [])
191
+ .filter((entry) => (
192
+ Number.isInteger(entry?.accepted_sequence)
193
+ && entry.accepted_sequence > assignedSequence
194
+ && concurrentIds.has(entry.turn_id)
195
+ ))
196
+ .sort((left, right) => left.accepted_sequence - right.accepted_sequence);
197
+
198
+ if (acceptedConcurrentSiblings.length === 0) {
199
+ return observation;
200
+ }
201
+
202
+ const siblingMarkersByFile = new Map();
203
+ for (const entry of acceptedConcurrentSiblings) {
204
+ const siblingFiles = Array.isArray(entry?.observed_artifact?.files_changed)
205
+ ? entry.observed_artifact.files_changed
206
+ : Array.isArray(entry?.files_changed)
207
+ ? entry.files_changed
208
+ : [];
209
+ const siblingMarkers = entry?.observed_artifact?.file_markers;
210
+ if (!siblingMarkers || typeof siblingMarkers !== 'object' || Array.isArray(siblingMarkers)) {
211
+ continue;
212
+ }
213
+ for (const filePath of siblingFiles) {
214
+ if (typeof siblingMarkers[filePath] === 'string' && siblingMarkers[filePath].length > 0) {
215
+ siblingMarkersByFile.set(filePath, siblingMarkers[filePath]);
216
+ }
217
+ }
218
+ }
219
+
220
+ if (siblingMarkersByFile.size === 0) {
221
+ return observation;
222
+ }
223
+
224
+ const nextFiles = [];
225
+ const nextMarkers = {};
226
+ const attributedToConcurrentSiblings = [];
227
+ const observationMarkers = observation?.file_markers && typeof observation.file_markers === 'object'
228
+ ? observation.file_markers
229
+ : {};
230
+
231
+ for (const filePath of observedFiles) {
232
+ const currentMarker = observationMarkers[filePath];
233
+ const siblingMarker = siblingMarkersByFile.get(filePath);
234
+ if (typeof siblingMarker === 'string' && siblingMarker === currentMarker) {
235
+ attributedToConcurrentSiblings.push(filePath);
236
+ continue;
237
+ }
238
+ nextFiles.push(filePath);
239
+ if (typeof currentMarker === 'string') {
240
+ nextMarkers[filePath] = currentMarker;
241
+ }
242
+ }
243
+
244
+ if (attributedToConcurrentSiblings.length === 0) {
245
+ return observation;
246
+ }
247
+
248
+ return {
249
+ ...observation,
250
+ files_changed: nextFiles,
251
+ file_markers: nextMarkers,
252
+ attributed_to_concurrent_siblings: attributedToConcurrentSiblings,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Build the file set used for acceptance-time conflict detection.
258
+ *
259
+ * Parallel attribution removes unchanged sibling files from the current turn's
260
+ * observed set so declared-vs-observed checks stay truthful. Conflict detection
261
+ * is stricter: if the agent declared a file that still appears in the raw
262
+ * baseline-to-now workspace union, that overlap must remain conflict-eligible
263
+ * even if the current file contents match a sibling's accepted marker.
264
+ *
265
+ * @param {object} rawObservation — unfiltered observeChanges() result
266
+ * @param {object} attributedObservation — result after attributeObservedChangesToTurn()
267
+ * @param {string[]} declaredFiles — turnResult.files_changed
268
+ * @returns {string[]}
269
+ */
270
+ export function buildConflictCandidateFiles(rawObservation, attributedObservation, declaredFiles = []) {
271
+ const conflictFiles = new Set(
272
+ Array.isArray(attributedObservation?.files_changed) ? attributedObservation.files_changed : [],
273
+ );
274
+ const rawObserved = new Set(
275
+ Array.isArray(rawObservation?.files_changed) ? rawObservation.files_changed : [],
276
+ );
277
+
278
+ for (const filePath of declaredFiles || []) {
279
+ if (rawObserved.has(filePath)) {
280
+ conflictFiles.add(filePath);
281
+ }
282
+ }
283
+
284
+ return [...conflictFiles].sort();
285
+ }
286
+
172
287
  /**
173
288
  * Classify observed file changes into added, modified, and deleted.
174
289
  *
@@ -284,13 +399,21 @@ function getFilteredChanges(root, baseRef, filter) {
284
399
  * @returns {object}
285
400
  */
286
401
  export function buildObservedArtifact(observation, baseline) {
287
- return {
402
+ const artifact = {
288
403
  derived_by: 'orchestrator',
289
404
  baseline_ref: baseline?.head_ref ? `git:${baseline.head_ref}` : null,
290
405
  accepted_ref: observation.head_ref ? `git:${observation.head_ref}` : 'workspace:dirty',
291
406
  files_changed: observation.files_changed,
407
+ file_markers: observation.file_markers || {},
292
408
  diff_summary: observation.diff_summary,
293
409
  };
410
+ // Preserve parallel attribution so operators can see which files were
411
+ // attributed to concurrent siblings and excluded from this turn.
412
+ const attributed = observation.attributed_to_concurrent_siblings;
413
+ if (Array.isArray(attributed) && attributed.length > 0) {
414
+ artifact.attributed_to_concurrent_siblings = attributed;
415
+ }
416
+ return artifact;
294
417
  }
295
418
 
296
419
  // ── Verification Normalization ──────────────────────────────────────────────
@@ -572,6 +695,14 @@ function getWorkspaceFileMarker(root, filePath) {
572
695
  }
573
696
  }
574
697
 
698
+ function buildFileMarkers(root, filePaths) {
699
+ const markers = {};
700
+ for (const filePath of filePaths || []) {
701
+ markers[filePath] = getWorkspaceFileMarker(root, filePath);
702
+ }
703
+ return markers;
704
+ }
705
+
575
706
  function getUntrackedFiles(root) {
576
707
  try {
577
708
  const result = execSync('git ls-files --others --exclude-standard', {