agentxchain 2.147.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.147.0",
3
+ "version": "2.148.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,15 @@ import {
31
31
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
32
32
  import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
33
33
 
34
+ const DIAGNOSTIC_ENV_KEYS = [
35
+ 'PATH',
36
+ 'HOME',
37
+ 'PWD',
38
+ 'SHELL',
39
+ 'TMPDIR',
40
+ 'AGENTXCHAIN_TURN_ID',
41
+ ];
42
+
34
43
  /**
35
44
  * Launch a local CLI subprocess for a governed turn.
36
45
  *
@@ -112,6 +121,10 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
112
121
 
113
122
  // Capture logs for dispatch record
114
123
  const logs = [];
124
+ const runtimeCwd = runtime.cwd ? join(root, runtime.cwd) : root;
125
+ const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
126
+ const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
127
+ const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
115
128
 
116
129
  return new Promise((resolve) => {
117
130
  if (signal?.aborted) {
@@ -121,12 +134,23 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
121
134
 
122
135
  let child;
123
136
  try {
137
+ appendDiagnostic(logs, 'spawn_prepare', {
138
+ runtime_id: runtimeId,
139
+ turn_id: turn.turn_id,
140
+ command,
141
+ args: diagnosticArgs,
142
+ cwd: runtimeCwd,
143
+ prompt_transport: transport,
144
+ stdin_bytes: stdinBytes,
145
+ env: pickDiagnosticEnv(spawnEnv),
146
+ });
124
147
  child = spawn(command, args, {
125
- cwd: runtime.cwd ? join(root, runtime.cwd) : root,
148
+ cwd: runtimeCwd,
126
149
  stdio: ['pipe', 'pipe', 'pipe'],
127
- env: { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id },
150
+ env: spawnEnv,
128
151
  });
129
152
  } catch (err) {
153
+ appendDiagnostic(logs, 'spawn_error', normalizeDiagnosticError(err));
130
154
  resolve({
131
155
  ok: false,
132
156
  startupFailure: true,
@@ -140,9 +164,13 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
140
164
  let settled = false;
141
165
  let firstOutputAt = null;
142
166
  let spawnConfirmedAt = null;
167
+ let spawnConfirmedAtMs = null;
168
+ let firstOutputLatencyMs = null;
143
169
  let startupWatchdog = null;
144
170
  let startupTimedOut = false;
145
171
  let startupFailureType = null;
172
+ let stdoutBytes = 0;
173
+ let stderrBytes = 0;
146
174
 
147
175
  const settle = (result) => {
148
176
  if (settled) return;
@@ -168,8 +196,14 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
168
196
  startupTimedOut = true;
169
197
  startupFailureType = 'no_subprocess_output';
170
198
  logs.push(`[adapter] Startup watchdog fired after ${Math.round(startupWatchdogMs / 1000)}s with no output.`);
171
- try {
172
- child.kill('SIGTERM');
199
+ appendDiagnostic(logs, 'startup_watchdog_fired', {
200
+ startup_watchdog_ms: startupWatchdogMs,
201
+ pid: child.pid ?? null,
202
+ spawn_confirmed_at: spawnConfirmedAt,
203
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
204
+ });
205
+ try {
206
+ child.kill('SIGTERM');
173
207
  } catch {}
174
208
  }, startupWatchdogMs);
175
209
  };
@@ -177,7 +211,14 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
177
211
  const recordFirstOutput = (stream) => {
178
212
  if (firstOutputAt) return;
179
213
  firstOutputAt = new Date().toISOString();
214
+ firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
180
215
  clearStartupWatchdog();
216
+ appendDiagnostic(logs, 'first_output', {
217
+ at: firstOutputAt,
218
+ stream,
219
+ pid: child.pid ?? null,
220
+ startup_latency_ms: firstOutputLatencyMs,
221
+ });
181
222
  if (onFirstOutput) {
182
223
  try {
183
224
  onFirstOutput({ pid: child.pid ?? null, at: firstOutputAt, stream });
@@ -186,7 +227,13 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
186
227
  };
187
228
 
188
229
  child.once('spawn', () => {
230
+ spawnConfirmedAtMs = Date.now();
189
231
  spawnConfirmedAt = new Date().toISOString();
232
+ appendDiagnostic(logs, 'spawn_attached', {
233
+ pid: child.pid ?? null,
234
+ at: spawnConfirmedAt,
235
+ startup_watchdog_ms: startupWatchdogMs,
236
+ });
190
237
  if (onSpawnAttached) {
191
238
  try {
192
239
  onSpawnAttached({ pid: child.pid ?? null, at: spawnConfirmedAt });
@@ -197,18 +244,32 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
197
244
 
198
245
  // Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
199
246
  if (child.stdin) {
247
+ child.stdin.on('error', (err) => {
248
+ appendDiagnostic(logs, 'stdin_error', {
249
+ at: new Date().toISOString(),
250
+ stdin_bytes: stdinBytes,
251
+ ...normalizeDiagnosticError(err),
252
+ });
253
+ });
200
254
  try {
201
255
  if (transport === 'stdin') {
202
256
  child.stdin.write(fullPrompt);
203
257
  }
204
258
  child.stdin.end();
205
- } catch {}
259
+ } catch (err) {
260
+ appendDiagnostic(logs, 'stdin_error', {
261
+ at: new Date().toISOString(),
262
+ stdin_bytes: stdinBytes,
263
+ ...normalizeDiagnosticError(err),
264
+ });
265
+ }
206
266
  }
207
267
 
208
268
  // Collect stdout/stderr
209
269
  if (child.stdout) {
210
270
  child.stdout.on('data', (chunk) => {
211
271
  const text = chunk.toString();
272
+ stdoutBytes += Buffer.byteLength(text);
212
273
  recordFirstOutput('stdout');
213
274
  logs.push(text);
214
275
  if (onStdout) onStdout(text);
@@ -218,6 +279,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
218
279
  if (child.stderr) {
219
280
  child.stderr.on('data', (chunk) => {
220
281
  const text = chunk.toString();
282
+ stderrBytes += Buffer.byteLength(text);
221
283
  recordFirstOutput('stderr');
222
284
  logs.push('[stderr] ' + text);
223
285
  if (onStderr) onStderr(text);
@@ -283,6 +345,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
283
345
  if (hasResult && !firstOutputAt) {
284
346
  recordFirstOutput('staged_result');
285
347
  }
348
+ const exitDiagnostic = {
349
+ pid: child.pid ?? null,
350
+ exit_code: exitCode,
351
+ signal: killSignal,
352
+ spawn_confirmed_at: spawnConfirmedAt,
353
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
354
+ first_output_at: firstOutputAt,
355
+ startup_latency_ms: firstOutputLatencyMs,
356
+ stdout_bytes: stdoutBytes,
357
+ stderr_bytes: stderrBytes,
358
+ staged_result_ready: hasResult,
359
+ };
360
+ if (startupTimedOut) {
361
+ exitDiagnostic.startup_failure_type = startupFailureType || 'no_subprocess_output';
362
+ } else if (!spawnConfirmedAt) {
363
+ exitDiagnostic.startup_failure_type = 'runtime_spawn_failed';
364
+ } else if (timedOut) {
365
+ exitDiagnostic.timed_out = true;
366
+ } else if (!firstOutputAt) {
367
+ exitDiagnostic.startup_failure_type = 'no_subprocess_output';
368
+ }
369
+ appendDiagnostic(logs, 'process_exit', exitDiagnostic);
286
370
 
287
371
  if (hasResult) {
288
372
  settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
@@ -344,6 +428,25 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
344
428
  clearTimeout(timeoutHandle);
345
429
  clearTimeout(sigkillHandle);
346
430
  if (signal) signal.removeEventListener('abort', onAbort);
431
+ // BUG-54 hypothesis #1 fix: explicitly release stdio streams on the
432
+ // error path so Node reclaims pipe handles immediately instead of
433
+ // waiting for GC. Without this, repeated `runtime_spawn_failed` turns
434
+ // leak ~4 handles per failure until the next GC sweep, which in a
435
+ // long-running `run --continuous` session can push the parent process
436
+ // toward its fd limit and cascade additional spawn failures.
437
+ try { child.stdin?.destroy(); } catch {}
438
+ try { child.stdout?.destroy(); } catch {}
439
+ try { child.stderr?.destroy(); } catch {}
440
+ appendDiagnostic(logs, 'spawn_error', {
441
+ pid: child.pid ?? null,
442
+ spawn_confirmed_at: spawnConfirmedAt,
443
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
444
+ first_output_at: firstOutputAt,
445
+ startup_latency_ms: firstOutputLatencyMs,
446
+ stdout_bytes: stdoutBytes,
447
+ stderr_bytes: stderrBytes,
448
+ ...normalizeDiagnosticError(err),
449
+ });
347
450
  settle({
348
451
  ok: false,
349
452
  startupFailure: !firstOutputAt,
@@ -458,4 +561,38 @@ function resolveTargetTurn(state, turnId) {
458
561
  return state?.current_turn || Object.values(state?.active_turns || {})[0];
459
562
  }
460
563
 
564
+ function appendDiagnostic(logs, label, payload) {
565
+ logs.push(`[adapter:diag] ${label} ${JSON.stringify(payload)}\n`);
566
+ }
567
+
568
+ function pickDiagnosticEnv(env) {
569
+ return Object.fromEntries(
570
+ DIAGNOSTIC_ENV_KEYS
571
+ .filter((key) => typeof env?.[key] === 'string' && env[key].length > 0)
572
+ .map((key) => [key, env[key]]),
573
+ );
574
+ }
575
+
576
+ function redactPromptArgs(args, fullPrompt, transport) {
577
+ const promptPlaceholder = `<prompt:${Buffer.byteLength(fullPrompt, 'utf8')} bytes>`;
578
+ return args.map((arg) => {
579
+ if (typeof arg !== 'string') {
580
+ return arg;
581
+ }
582
+ if (transport === 'argv' && arg === fullPrompt) {
583
+ return promptPlaceholder;
584
+ }
585
+ return arg;
586
+ });
587
+ }
588
+
589
+ function normalizeDiagnosticError(err) {
590
+ return {
591
+ code: err?.code || null,
592
+ errno: err?.errno || null,
593
+ syscall: err?.syscall || null,
594
+ message: err?.message || String(err),
595
+ };
596
+ }
597
+
461
598
  export { resolvePromptTransport };
@@ -3729,8 +3729,37 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3729
3729
  ],
3730
3730
  );
3731
3731
  if (!dirtyParity.clean) {
3732
- transitionToFailedAcceptance(root, state, currentTurn, dirtyParity.reason, {
3733
- error_code: 'artifact_dirty_tree_mismatch',
3732
+ // BUG-55 sub-defect B: when the turn declared verification commands or
3733
+ // machine evidence, undeclared dirty files are most likely verification
3734
+ // outputs that need classification under verification.produced_files
3735
+ // (disposition 'ignore' to clean up, or 'artifact' to include in the
3736
+ // checkpoint). Surface a dedicated error class + message so the agent
3737
+ // knows the correct remediation surface, instead of the generic
3738
+ // files_changed-or-produced_files-or-clean advice that the non-
3739
+ // verification path emits.
3740
+ const verification = turnResult.verification && typeof turnResult.verification === 'object'
3741
+ ? turnResult.verification
3742
+ : {};
3743
+ const declaredVerificationCommands = Array.isArray(verification.commands)
3744
+ && verification.commands.some((c) => typeof c === 'string' && c.trim().length > 0);
3745
+ const declaredMachineEvidence = Array.isArray(verification.machine_evidence)
3746
+ && verification.machine_evidence.some((e) => e && typeof e === 'object' && typeof e.command === 'string' && e.command.trim().length > 0);
3747
+ const verificationWasDeclared = declaredVerificationCommands || declaredMachineEvidence;
3748
+
3749
+ let failureReason = dirtyParity.reason;
3750
+ let failureErrorCode = 'artifact_dirty_tree_mismatch';
3751
+ if (verificationWasDeclared) {
3752
+ failureErrorCode = 'undeclared_verification_outputs';
3753
+ const undeclared = Array.isArray(dirtyParity.unexpected_dirty_files)
3754
+ ? dirtyParity.unexpected_dirty_files
3755
+ : [];
3756
+ const listForMessage = undeclared.slice(0, 5).join(', ')
3757
+ + (undeclared.length > 5 ? '...' : '');
3758
+ failureReason = `Verification was declared (commands or machine_evidence), but these files are dirty and not classified: ${listForMessage}. Classify each under verification.produced_files with disposition "ignore" (the file should be cleaned up after replay) or "artifact" (the file should be checkpointed as part of the turn), OR add it to files_changed if it is a core turn mutation. Acceptance cannot proceed until the declared contract matches the working tree.`;
3759
+ }
3760
+
3761
+ transitionToFailedAcceptance(root, state, currentTurn, failureReason, {
3762
+ error_code: failureErrorCode,
3734
3763
  stage: 'artifact_observation',
3735
3764
  extra: {
3736
3765
  unexpected_dirty_files: dirtyParity.unexpected_dirty_files,
@@ -3739,13 +3768,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3739
3768
  });
3740
3769
  return {
3741
3770
  ok: false,
3742
- error: dirtyParity.reason,
3771
+ error: failureReason,
3772
+ error_code: failureErrorCode,
3743
3773
  validation: {
3744
3774
  ...validation,
3745
3775
  ok: false,
3746
3776
  stage: 'artifact_observation',
3747
3777
  error_class: 'artifact_error',
3748
- errors: [dirtyParity.reason],
3778
+ errors: [failureReason],
3749
3779
  warnings: validation.warnings,
3750
3780
  },
3751
3781
  };
@@ -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
  }
@@ -139,6 +139,53 @@ function buildCheckpointCommit(entry) {
139
139
  return { subject, body: bodyLines.join('\n') };
140
140
  }
141
141
 
142
+ function diffMissingDeclaredPaths(declaredFiles, stagedFiles) {
143
+ const stagedSet = new Set((Array.isArray(stagedFiles) ? stagedFiles : []).map((value) => value.trim()).filter(Boolean));
144
+ return (Array.isArray(declaredFiles) ? declaredFiles : []).filter((filePath) => !stagedSet.has(filePath));
145
+ }
146
+
147
+ /**
148
+ * Partition paths that were missing from the staged-diff into
149
+ * (a) paths genuinely absent from git (untracked or dirty without staging)
150
+ * (b) paths already committed upstream (tracked in HEAD, no pending diff)
151
+ *
152
+ * BUG-55A completeness must only fail on (a). An actor that committed a
153
+ * declared file before `checkpoint-turn` ran (see BUG-23 scenario) is
154
+ * already-checkpointed-upstream; treating that as "missing from checkpoint"
155
+ * is a false positive from the completeness gate.
156
+ */
157
+ function partitionDeclaredPathsByUpstreamPresence(root, missingPaths) {
158
+ const genuinelyMissing = [];
159
+ const alreadyCommittedUpstream = [];
160
+ for (const filePath of missingPaths) {
161
+ let tracked = false;
162
+ try {
163
+ git(root, ['ls-files', '--error-unmatch', '--', filePath]);
164
+ tracked = true;
165
+ } catch {
166
+ tracked = false;
167
+ }
168
+ if (!tracked) {
169
+ genuinelyMissing.push(filePath);
170
+ continue;
171
+ }
172
+ let hasDivergence = false;
173
+ try {
174
+ const headDiff = git(root, ['diff', 'HEAD', '--', filePath]);
175
+ const cachedDiff = git(root, ['diff', '--cached', '--', filePath]);
176
+ hasDivergence = Boolean(headDiff) || Boolean(cachedDiff);
177
+ } catch {
178
+ hasDivergence = true;
179
+ }
180
+ if (hasDivergence) {
181
+ genuinelyMissing.push(filePath);
182
+ } else {
183
+ alreadyCommittedUpstream.push(filePath);
184
+ }
185
+ }
186
+ return { genuinelyMissing, alreadyCommittedUpstream };
187
+ }
188
+
142
189
  export function detectPendingCheckpoint(root, dirtyFiles = []) {
143
190
  const actorDirtyFiles = normalizeFilesChanged(dirtyFiles);
144
191
  if (actorDirtyFiles.length === 0) return { required: false };
@@ -233,12 +280,29 @@ export function checkpointAcceptedTurn(root, opts = {}) {
233
280
  };
234
281
  }
235
282
 
283
+ const rawMissingFromStage = diffMissingDeclaredPaths(filesChanged, staged);
284
+ const { genuinelyMissing, alreadyCommittedUpstream } =
285
+ partitionDeclaredPathsByUpstreamPresence(root, rawMissingFromStage);
286
+ if (genuinelyMissing.length > 0) {
287
+ return {
288
+ ok: false,
289
+ turn: entry,
290
+ error: `Checkpoint completeness failure: accepted turn ${entry.turn_id} declared ${filesChanged.length} checkpointable file(s), but Git staged only ${staged.length} and ${genuinelyMissing.length} declared path(s) are absent from git. Missing from checkpoint: ${genuinelyMissing.join(', ')}.`,
291
+ missing_declared_paths: genuinelyMissing,
292
+ already_committed_upstream: alreadyCommittedUpstream,
293
+ staged_paths: staged,
294
+ };
295
+ }
296
+
236
297
  if (staged.length === 0) {
237
298
  return {
238
299
  ok: true,
239
300
  skipped: true,
240
301
  turn: entry,
241
- reason: `Accepted turn ${entry.turn_id} has no staged repo changes to checkpoint.`,
302
+ reason: alreadyCommittedUpstream.length > 0
303
+ ? `Accepted turn ${entry.turn_id} has no staged repo changes to checkpoint; all ${alreadyCommittedUpstream.length} declared file(s) already present in HEAD.`
304
+ : `Accepted turn ${entry.turn_id} has no staged repo changes to checkpoint.`,
305
+ already_committed_upstream: alreadyCommittedUpstream,
242
306
  };
243
307
  }
244
308
 
@@ -600,6 +600,15 @@ function validateVerification(tr) {
600
600
  }
601
601
  }
602
602
 
603
+ if (Array.isArray(v.commands)) {
604
+ for (let i = 0; i < v.commands.length; i++) {
605
+ const command = v.commands[i];
606
+ if (typeof command !== 'string' || command.trim().length === 0) {
607
+ errors.push(`verification.commands[${i}] must be a non-empty string.`);
608
+ }
609
+ }
610
+ }
611
+
603
612
  // machine_evidence exit codes should be consistent with status
604
613
  if (Array.isArray(v.machine_evidence)) {
605
614
  for (let i = 0; i < v.machine_evidence.length; i++) {
@@ -608,8 +617,8 @@ function validateVerification(tr) {
608
617
  errors.push(`verification.machine_evidence[${i}] must be an object.`);
609
618
  continue;
610
619
  }
611
- if (typeof entry.command !== 'string') {
612
- errors.push(`verification.machine_evidence[${i}].command must be a string.`);
620
+ if (typeof entry.command !== 'string' || entry.command.trim().length === 0) {
621
+ errors.push(`verification.machine_evidence[${i}].command must be a non-empty string.`);
613
622
  }
614
623
  if (typeof entry.exit_code !== 'number' || !Number.isInteger(entry.exit_code)) {
615
624
  errors.push(`verification.machine_evidence[${i}].exit_code must be an integer.`);