agentxchain 2.148.0 → 2.149.1

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.
@@ -115,8 +115,21 @@ function formatDispatchActivity(progress) {
115
115
  if (progress.activity_type === 'response') {
116
116
  return 'API response received';
117
117
  }
118
- const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
119
- return `Producing output (${progress.output_lines || 0} lines${agoLabel})`;
118
+ if (progress.activity_type === 'diagnostic_only') {
119
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
120
+ return `Diagnostic output only (${progress.stderr_lines || 0} stderr lines, no stdout yet${agoLabel})`;
121
+ }
122
+ if (progress.activity_type === 'output') {
123
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
124
+ return `Producing output (${progress.output_lines || 0} lines${agoLabel})`;
125
+ }
126
+ if (progress.activity_type === 'starting') {
127
+ return progress.activity_summary || 'Waiting for first output';
128
+ }
129
+ if (typeof progress.activity_summary === 'string' && progress.activity_summary.trim()) {
130
+ return progress.activity_summary.trim();
131
+ }
132
+ return null;
120
133
  }
121
134
 
122
135
  function statusBadge(status) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.148.0",
3
+ "version": "2.149.1",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * BUG-54 reproduction harness.
4
+ *
5
+ * Purpose:
6
+ * Reproduce, with full diagnostic capture, the exact spawn shape the
7
+ * `local_cli` adapter uses (`cli/src/lib/adapters/local-cli-adapter.js`).
8
+ * Eliminates every layer between the adapter and the subprocess so the
9
+ * tester (and the agents) can see the raw behavior of `claude -p ...`
10
+ * (or whatever the configured runtime is) under repeated invocation.
11
+ *
12
+ * Why this exists:
13
+ * HUMAN-ROADMAP demands diagnostic proof BEFORE any further code fix on
14
+ * BUG-54. Twenty-seven post-v2.148.0 commits refined classification /
15
+ * display, none of them captured the underlying spawn behavior. This
16
+ * script captures exactly what the adapter sees, with no truncation, no
17
+ * classification, and no governed-state machinery between us and the
18
+ * process.
19
+ *
20
+ * What it captures, per attempt:
21
+ * - Pre-spawn snapshot:
22
+ * resolved command + args (with a redacted prompt placeholder)
23
+ * cwd
24
+ * prompt transport
25
+ * stdin byte count
26
+ * diagnostic env keys present (PATH/HOME/PWD/SHELL/TMPDIR + auth-key
27
+ * presence flags — values are NEVER captured)
28
+ * - Spawn lifecycle:
29
+ * spawn_attempted_at, spawn_attached_at, pid
30
+ * first stdout byte timestamp + line count
31
+ * first stderr byte timestamp + line count
32
+ * startup watchdog firing (if any) with elapsed time
33
+ * process_exit: exit code, signal, wall-clock duration
34
+ * - Output (FULL, NO TRUNCATION):
35
+ * stdout (raw)
36
+ * stderr (raw)
37
+ * - Spawn-failure errors:
38
+ * code, errno, syscall, message
39
+ *
40
+ * Usage:
41
+ * node cli/scripts/reproduce-bug-54.mjs # auto-discover, 5 attempts
42
+ * node cli/scripts/reproduce-bug-54.mjs --attempts 10
43
+ * node cli/scripts/reproduce-bug-54.mjs --runtime local-claude
44
+ * node cli/scripts/reproduce-bug-54.mjs --turn-id turn_abc123
45
+ * node cli/scripts/reproduce-bug-54.mjs --synthetic "Say only the word READY and nothing else."
46
+ * node cli/scripts/reproduce-bug-54.mjs --watchdog-ms 60000
47
+ * node cli/scripts/reproduce-bug-54.mjs --no-watchdog
48
+ * node cli/scripts/reproduce-bug-54.mjs --out /tmp/bug54-evidence.json
49
+ *
50
+ * Output:
51
+ * - Live progress to stderr (one line per lifecycle event, per attempt).
52
+ * - Full JSON capture to --out path (default ./bug-54-repro-<ts>.json).
53
+ * - Tester runs this and shares the JSON file. That JSON is the artifact
54
+ * the agents need to actually identify root cause.
55
+ *
56
+ * This script does NOT modify any state, does NOT write into
57
+ * `.agentxchain/staging/` (it ignores the staged-result contract on
58
+ * purpose), and does NOT require the governed dispatcher to be running.
59
+ */
60
+
61
+ import { spawn } from 'child_process';
62
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
63
+ import { join, resolve } from 'path';
64
+ import { fileURLToPath } from 'url';
65
+
66
+ import {
67
+ resolveCommand,
68
+ resolvePromptTransport,
69
+ resolveStartupWatchdogMs,
70
+ } from '../src/lib/adapters/local-cli-adapter.js';
71
+
72
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
73
+ const REPO_ROOT_GUESS = resolve(SCRIPT_DIR, '..', '..');
74
+
75
+ const DIAGNOSTIC_ENV_KEYS = ['PATH', 'HOME', 'PWD', 'SHELL', 'TMPDIR'];
76
+ const AUTH_ENV_KEYS_TO_PROBE = [
77
+ 'ANTHROPIC_API_KEY',
78
+ 'CLAUDE_API_KEY',
79
+ 'CLAUDE_CODE_OAUTH_TOKEN',
80
+ 'CLAUDE_CODE_USE_VERTEX',
81
+ 'CLAUDE_CODE_USE_BEDROCK',
82
+ ];
83
+
84
+ // ──────────────────────────────────────────────────────────────────────────────
85
+ // Argv parsing — kept simple/explicit so tester can read what their flags do.
86
+ // ──────────────────────────────────────────────────────────────────────────────
87
+
88
+ function parseArgs(argv) {
89
+ const opts = {
90
+ runtimeId: null,
91
+ turnId: null,
92
+ synthetic: null,
93
+ attempts: 5,
94
+ watchdogMs: null, // null => use adapter default
95
+ noWatchdog: false,
96
+ out: null,
97
+ delayBetweenMs: 500,
98
+ cwdOverride: null,
99
+ };
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const a = argv[i];
102
+ const next = () => argv[++i];
103
+ if (a === '--runtime' || a === '--runtime-id') opts.runtimeId = next();
104
+ else if (a === '--turn-id') opts.turnId = next();
105
+ else if (a === '--synthetic') opts.synthetic = next();
106
+ else if (a === '--attempts') opts.attempts = Number.parseInt(next(), 10);
107
+ else if (a === '--watchdog-ms') opts.watchdogMs = Number.parseInt(next(), 10);
108
+ else if (a === '--no-watchdog') opts.noWatchdog = true;
109
+ else if (a === '--out') opts.out = next();
110
+ else if (a === '--delay-ms') opts.delayBetweenMs = Number.parseInt(next(), 10);
111
+ else if (a === '--cwd') opts.cwdOverride = next();
112
+ else if (a === '-h' || a === '--help') { printHelpAndExit(); }
113
+ else { console.error(`[repro] unknown arg: ${a}`); printHelpAndExit(1); }
114
+ }
115
+ return opts;
116
+ }
117
+
118
+ function printHelpAndExit(code = 0) {
119
+ console.error(`Usage: node cli/scripts/reproduce-bug-54.mjs [options]
120
+
121
+ --runtime <id> local_cli runtime to test (default: first local_cli runtime in agentxchain.json)
122
+ --turn-id <id> replay an existing dispatch bundle's prompt (default: most recent dispatch turn)
123
+ --synthetic "<prompt>" use a minimal synthetic prompt instead of a dispatch bundle
124
+ (isolates spawn behavior from prompt content)
125
+ --attempts <N> number of consecutive spawn attempts (default: 5)
126
+ --watchdog-ms <ms> override startup watchdog (default: adapter default, usually 30000)
127
+ --no-watchdog do not kill subprocess on watchdog timeout
128
+ --delay-ms <ms> delay between consecutive attempts (default: 500)
129
+ --cwd <path> override project root (default: auto-discover)
130
+ --out <path> write full JSON capture here (default: ./bug-54-repro-<ts>.json)
131
+ -h, --help show this help
132
+
133
+ Examples:
134
+ # Default: discover real local_cli runtime, replay last dispatch, 5 attempts
135
+ node cli/scripts/reproduce-bug-54.mjs
136
+
137
+ # Isolate spawn from prompt content (recommended first run)
138
+ node cli/scripts/reproduce-bug-54.mjs --synthetic "Say READY and nothing else."
139
+
140
+ # Reproduce the operator's exact failing turn
141
+ node cli/scripts/reproduce-bug-54.mjs --turn-id turn_abc123 --attempts 10
142
+ `);
143
+ process.exit(code);
144
+ }
145
+
146
+ // ──────────────────────────────────────────────────────────────────────────────
147
+ // Project + runtime discovery (mirrors loadConfig but lighter; no schema dep
148
+ // chain so the script runs even on a partially-broken install).
149
+ // ──────────────────────────────────────────────────────────────────────────────
150
+
151
+ function findProjectRoot(startDir) {
152
+ let dir = resolve(startDir);
153
+ while (true) {
154
+ if (existsSync(join(dir, 'agentxchain.json'))) return dir;
155
+ const parent = resolve(dir, '..');
156
+ if (parent === dir) return null;
157
+ dir = parent;
158
+ }
159
+ }
160
+
161
+ function loadConfigRaw(root) {
162
+ const filePath = join(root, 'agentxchain.json');
163
+ return JSON.parse(readFileSync(filePath, 'utf8'));
164
+ }
165
+
166
+ function pickLocalCliRuntime(config, requestedId) {
167
+ const runtimes = config.runtimes || {};
168
+ if (requestedId) {
169
+ const runtime = runtimes[requestedId];
170
+ if (!runtime) throw new Error(`Runtime "${requestedId}" not found in agentxchain.json`);
171
+ if (runtime.type && runtime.type !== 'local_cli') {
172
+ throw new Error(`Runtime "${requestedId}" is type "${runtime.type}", not local_cli`);
173
+ }
174
+ return { id: requestedId, runtime };
175
+ }
176
+ for (const [id, runtime] of Object.entries(runtimes)) {
177
+ if ((runtime?.type ?? 'local_cli') === 'local_cli' && runtime?.command) {
178
+ return { id, runtime };
179
+ }
180
+ }
181
+ throw new Error('No local_cli runtime with a "command" field found in agentxchain.json');
182
+ }
183
+
184
+ // ──────────────────────────────────────────────────────────────────────────────
185
+ // Dispatch-bundle resolution — mirror the adapter's prompt-source logic.
186
+ // ──────────────────────────────────────────────────────────────────────────────
187
+
188
+ function findMostRecentTurnId(root) {
189
+ const turnsDir = join(root, '.agentxchain', 'dispatch', 'turns');
190
+ if (!existsSync(turnsDir)) return null;
191
+ const entries = readdirSync(turnsDir)
192
+ .map((name) => {
193
+ const path = join(turnsDir, name);
194
+ try {
195
+ const stat = statSync(path);
196
+ return stat.isDirectory() ? { name, mtime: stat.mtimeMs } : null;
197
+ } catch { return null; }
198
+ })
199
+ .filter(Boolean)
200
+ .sort((a, b) => b.mtime - a.mtime);
201
+ return entries[0]?.name ?? null;
202
+ }
203
+
204
+ function loadPromptForTurn(root, turnId) {
205
+ const promptPath = join(root, '.agentxchain', 'dispatch', 'turns', turnId, 'PROMPT.md');
206
+ const contextPath = join(root, '.agentxchain', 'dispatch', 'turns', turnId, 'CONTEXT.md');
207
+ if (!existsSync(promptPath)) {
208
+ throw new Error(`Dispatch bundle not found: ${promptPath}`);
209
+ }
210
+ const prompt = readFileSync(promptPath, 'utf8');
211
+ const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
212
+ return { fullPrompt: prompt + '\n---\n\n' + context, promptPath, contextPath };
213
+ }
214
+
215
+ function redactArgs(args, fullPrompt, transport) {
216
+ const placeholder = `<prompt:${Buffer.byteLength(fullPrompt, 'utf8')} bytes>`;
217
+ return args.map((a) => {
218
+ if (typeof a !== 'string') return a;
219
+ if (transport === 'argv' && a === fullPrompt) return placeholder;
220
+ return a;
221
+ });
222
+ }
223
+
224
+ function snapshotEnv(env) {
225
+ const visible = {};
226
+ for (const k of DIAGNOSTIC_ENV_KEYS) {
227
+ if (typeof env[k] === 'string' && env[k].length > 0) visible[k] = env[k];
228
+ }
229
+ const authProbe = {};
230
+ for (const k of AUTH_ENV_KEYS_TO_PROBE) {
231
+ authProbe[k] = typeof env[k] === 'string' && env[k].length > 0;
232
+ }
233
+ return { visible, auth_env_present: authProbe };
234
+ }
235
+
236
+ // ──────────────────────────────────────────────────────────────────────────────
237
+ // One spawn attempt. Captures everything; never throws.
238
+ // ──────────────────────────────────────────────────────────────────────────────
239
+
240
+ async function runOneAttempt({
241
+ attemptIndex,
242
+ command,
243
+ args,
244
+ cwd,
245
+ env,
246
+ transport,
247
+ fullPrompt,
248
+ watchdogMs,
249
+ noWatchdog,
250
+ }) {
251
+ const t0 = Date.now();
252
+ const attempt = {
253
+ attempt_index: attemptIndex,
254
+ spawn_attempted_at: new Date(t0).toISOString(),
255
+ pid: null,
256
+ spawn_attached_at: null,
257
+ spawn_attached_elapsed_ms: null,
258
+ first_stdout_at: null,
259
+ first_stdout_elapsed_ms: null,
260
+ first_stderr_at: null,
261
+ first_stderr_elapsed_ms: null,
262
+ stdout_lines: 0,
263
+ stderr_lines: 0,
264
+ stdout_bytes: 0,
265
+ stderr_bytes: 0,
266
+ stdout: '',
267
+ stderr: '',
268
+ watchdog_fired: false,
269
+ watchdog_elapsed_ms: null,
270
+ spawn_error: null, // sync throw at spawn() — process never created
271
+ process_error: null, // 'error' event from subprocess
272
+ exit_code: null,
273
+ exit_signal: null,
274
+ exit_at: null,
275
+ exit_elapsed_ms: null,
276
+ classification: null, // computed at end
277
+ };
278
+
279
+ return await new Promise((resolveAttempt) => {
280
+ let child;
281
+ let watchdogTimer = null;
282
+ let settled = false;
283
+
284
+ const settle = () => {
285
+ if (settled) return;
286
+ settled = true;
287
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
288
+ attempt.classification = classifyAttempt(attempt);
289
+ resolveAttempt(attempt);
290
+ };
291
+
292
+ try {
293
+ child = spawn(command, args, {
294
+ cwd,
295
+ stdio: ['pipe', 'pipe', 'pipe'],
296
+ env,
297
+ });
298
+ } catch (err) {
299
+ attempt.spawn_error = {
300
+ code: err?.code ?? null,
301
+ errno: err?.errno ?? null,
302
+ syscall: err?.syscall ?? null,
303
+ message: err?.message || String(err),
304
+ };
305
+ attempt.exit_at = new Date().toISOString();
306
+ attempt.exit_elapsed_ms = Date.now() - t0;
307
+ settle();
308
+ return;
309
+ }
310
+
311
+ child.on('spawn', () => {
312
+ const now = Date.now();
313
+ attempt.pid = child.pid ?? null;
314
+ attempt.spawn_attached_at = new Date(now).toISOString();
315
+ attempt.spawn_attached_elapsed_ms = now - t0;
316
+ });
317
+
318
+ if (child.stdin) {
319
+ child.stdin.on('error', (err) => {
320
+ // Capture but do not fail — adapter behavior matches: stdin EPIPE is
321
+ // logged and the spawn continues to play out via close/error events.
322
+ attempt.stderr += `[repro:stdin_error] ${err?.code || ''} ${err?.message || ''}\n`;
323
+ });
324
+ try {
325
+ if (transport === 'stdin') child.stdin.write(fullPrompt);
326
+ child.stdin.end();
327
+ } catch (err) {
328
+ attempt.stderr += `[repro:stdin_throw] ${err?.code || ''} ${err?.message || ''}\n`;
329
+ }
330
+ }
331
+
332
+ if (child.stdout) {
333
+ child.stdout.on('data', (chunk) => {
334
+ const text = chunk.toString();
335
+ if (!attempt.first_stdout_at) {
336
+ const now = Date.now();
337
+ attempt.first_stdout_at = new Date(now).toISOString();
338
+ attempt.first_stdout_elapsed_ms = now - t0;
339
+ }
340
+ attempt.stdout += text;
341
+ attempt.stdout_bytes += Buffer.byteLength(text);
342
+ attempt.stdout_lines += (text.match(/\n/g) || []).length;
343
+ });
344
+ }
345
+
346
+ if (child.stderr) {
347
+ child.stderr.on('data', (chunk) => {
348
+ const text = chunk.toString();
349
+ if (!attempt.first_stderr_at) {
350
+ const now = Date.now();
351
+ attempt.first_stderr_at = new Date(now).toISOString();
352
+ attempt.first_stderr_elapsed_ms = now - t0;
353
+ }
354
+ attempt.stderr += text;
355
+ attempt.stderr_bytes += Buffer.byteLength(text);
356
+ attempt.stderr_lines += (text.match(/\n/g) || []).length;
357
+ });
358
+ }
359
+
360
+ if (!noWatchdog && Number.isFinite(watchdogMs) && watchdogMs > 0) {
361
+ watchdogTimer = setTimeout(() => {
362
+ if (attempt.first_stdout_at || attempt.first_stderr_at) {
363
+ // Match adapter behavior: if anything came out we still let the
364
+ // adapter's no-output rule decide. But here we mark fire either way.
365
+ }
366
+ attempt.watchdog_fired = true;
367
+ attempt.watchdog_elapsed_ms = Date.now() - t0;
368
+ try { child.kill('SIGTERM'); } catch {}
369
+ // Give 2s then SIGKILL just in case
370
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000);
371
+ }, watchdogMs);
372
+ }
373
+
374
+ child.on('error', (err) => {
375
+ attempt.process_error = {
376
+ code: err?.code ?? null,
377
+ errno: err?.errno ?? null,
378
+ syscall: err?.syscall ?? null,
379
+ message: err?.message || String(err),
380
+ };
381
+ // 'error' may fire before or instead of 'close'; settle if no close coming
382
+ if (!child.exitCode && !child.signalCode) {
383
+ // Wait briefly for close; if it doesn't come, settle.
384
+ setTimeout(() => {
385
+ if (!settled && attempt.exit_at == null) {
386
+ attempt.exit_at = new Date().toISOString();
387
+ attempt.exit_elapsed_ms = Date.now() - t0;
388
+ settle();
389
+ }
390
+ }, 250);
391
+ }
392
+ });
393
+
394
+ child.on('close', (code, signal) => {
395
+ const now = Date.now();
396
+ attempt.exit_code = code;
397
+ attempt.exit_signal = signal;
398
+ attempt.exit_at = new Date(now).toISOString();
399
+ attempt.exit_elapsed_ms = now - t0;
400
+ settle();
401
+ });
402
+ });
403
+ }
404
+
405
+ function classifyAttempt(a) {
406
+ if (a.spawn_error) return 'spawn_error_pre_process';
407
+ if (a.process_error && !a.spawn_attached_at) return 'spawn_attach_failed';
408
+ if (!a.spawn_attached_at) return 'spawn_unattached';
409
+ if (a.watchdog_fired && !a.first_stdout_at && !a.first_stderr_at) return 'watchdog_no_output';
410
+ if (a.watchdog_fired && !a.first_stdout_at) return 'watchdog_stderr_only';
411
+ if (!a.first_stdout_at && !a.first_stderr_at) return 'exit_no_output';
412
+ if (!a.first_stdout_at) return 'exit_stderr_only';
413
+ if (a.exit_code === 0) return 'exit_clean_with_stdout';
414
+ return 'exit_nonzero_with_stdout';
415
+ }
416
+
417
+ // ──────────────────────────────────────────────────────────────────────────────
418
+ // Main
419
+ // ──────────────────────────────────────────────────────────────────────────────
420
+
421
+ async function main() {
422
+ const opts = parseArgs(process.argv.slice(2));
423
+ const startDir = opts.cwdOverride || process.cwd();
424
+ let root = findProjectRoot(startDir);
425
+ if (!root) root = REPO_ROOT_GUESS;
426
+ if (!root || !existsSync(join(root, 'agentxchain.json'))) {
427
+ console.error(`[repro] could not find an agentxchain.json starting from ${startDir}`);
428
+ console.error(`[repro] use --cwd <path> to point at a project root`);
429
+ process.exit(2);
430
+ }
431
+
432
+ let configRaw;
433
+ try {
434
+ configRaw = loadConfigRaw(root);
435
+ } catch (err) {
436
+ console.error(`[repro] failed to read agentxchain.json at ${root}: ${err.message}`);
437
+ process.exit(2);
438
+ }
439
+
440
+ let runtimeChoice;
441
+ try {
442
+ runtimeChoice = pickLocalCliRuntime(configRaw, opts.runtimeId);
443
+ } catch (err) {
444
+ console.error(`[repro] ${err.message}`);
445
+ process.exit(2);
446
+ }
447
+ const { id: runtimeId, runtime } = runtimeChoice;
448
+
449
+ // Resolve prompt
450
+ let fullPrompt;
451
+ let promptSource;
452
+ if (opts.synthetic) {
453
+ fullPrompt = opts.synthetic;
454
+ promptSource = { kind: 'synthetic', length_bytes: Buffer.byteLength(fullPrompt) };
455
+ } else {
456
+ const turnId = opts.turnId || findMostRecentTurnId(root);
457
+ if (!turnId) {
458
+ // No real bundle available — fall back to a synthetic prompt and warn.
459
+ fullPrompt = 'Say only the word READY and nothing else. Do not reason out loud.';
460
+ promptSource = {
461
+ kind: 'synthetic_fallback',
462
+ reason: 'no dispatch bundle found; pass --turn-id or --synthetic to override',
463
+ length_bytes: Buffer.byteLength(fullPrompt),
464
+ };
465
+ } else {
466
+ try {
467
+ const loaded = loadPromptForTurn(root, turnId);
468
+ fullPrompt = loaded.fullPrompt;
469
+ promptSource = {
470
+ kind: 'dispatch_bundle',
471
+ turn_id: turnId,
472
+ prompt_path: loaded.promptPath,
473
+ context_path: loaded.contextPath,
474
+ length_bytes: Buffer.byteLength(fullPrompt),
475
+ };
476
+ } catch (err) {
477
+ console.error(`[repro] failed to load dispatch bundle for ${turnId}: ${err.message}`);
478
+ process.exit(2);
479
+ }
480
+ }
481
+ }
482
+
483
+ const transport = resolvePromptTransport(runtime);
484
+ const { command, args } = resolveCommand(runtime, fullPrompt);
485
+ if (!command) {
486
+ console.error(`[repro] runtime "${runtimeId}" has no resolvable command`);
487
+ process.exit(2);
488
+ }
489
+
490
+ const watchdogMs = opts.watchdogMs ?? resolveStartupWatchdogMs(configRaw, runtime);
491
+ const runtimeCwd = runtime.cwd ? join(root, runtime.cwd) : root;
492
+
493
+ // Synthetic turn id so the subprocess sees the same env shape the adapter sets
494
+ const fakeTurnId = `turn_repro_${Date.now()}`;
495
+ const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: fakeTurnId };
496
+ const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt) : 0;
497
+ const diagnosticArgs = redactArgs(args, fullPrompt, transport);
498
+ const envSnapshot = snapshotEnv(spawnEnv);
499
+
500
+ const header = {
501
+ repro_version: 1,
502
+ started_at: new Date().toISOString(),
503
+ project_root: root,
504
+ runtime_id: runtimeId,
505
+ runtime_type: runtime.type ?? 'local_cli',
506
+ resolved_command: command,
507
+ resolved_args_redacted: diagnosticArgs,
508
+ cwd: runtimeCwd,
509
+ prompt_transport: transport,
510
+ stdin_bytes: stdinBytes,
511
+ prompt_source: promptSource,
512
+ env_snapshot: envSnapshot,
513
+ watchdog_ms: opts.noWatchdog ? null : watchdogMs,
514
+ no_watchdog: opts.noWatchdog,
515
+ attempts_planned: opts.attempts,
516
+ delay_between_ms: opts.delayBetweenMs,
517
+ fake_turn_id: fakeTurnId,
518
+ node_version: process.version,
519
+ platform: process.platform,
520
+ arch: process.arch,
521
+ };
522
+
523
+ console.error('[repro] BUG-54 reproduction harness');
524
+ console.error(`[repro] root : ${header.project_root}`);
525
+ console.error(`[repro] runtime : ${header.runtime_id} (${header.runtime_type})`);
526
+ console.error(`[repro] command : ${header.resolved_command} ${JSON.stringify(header.resolved_args_redacted)}`);
527
+ console.error(`[repro] transport : ${header.prompt_transport} (stdin_bytes=${header.stdin_bytes})`);
528
+ console.error(`[repro] cwd : ${header.cwd}`);
529
+ console.error(`[repro] prompt : ${promptSource.kind} (${promptSource.length_bytes} bytes)`);
530
+ console.error(`[repro] watchdog_ms : ${header.watchdog_ms ?? 'disabled'}`);
531
+ console.error(`[repro] auth env : ${JSON.stringify(envSnapshot.auth_env_present)}`);
532
+ console.error(`[repro] attempts : ${header.attempts_planned}`);
533
+ console.error('');
534
+
535
+ const attempts = [];
536
+ for (let i = 1; i <= opts.attempts; i++) {
537
+ console.error(`[repro] attempt ${i}/${opts.attempts} starting...`);
538
+ const result = await runOneAttempt({
539
+ attemptIndex: i,
540
+ command,
541
+ args,
542
+ cwd: runtimeCwd,
543
+ env: spawnEnv,
544
+ transport,
545
+ fullPrompt,
546
+ watchdogMs,
547
+ noWatchdog: opts.noWatchdog,
548
+ });
549
+ attempts.push(result);
550
+ console.error(
551
+ `[repro] attempt ${i} -> classification=${result.classification} ` +
552
+ `pid=${result.pid ?? '-'} ` +
553
+ `attached_ms=${result.spawn_attached_elapsed_ms ?? '-'} ` +
554
+ `first_stdout_ms=${result.first_stdout_elapsed_ms ?? '-'} ` +
555
+ `first_stderr_ms=${result.first_stderr_elapsed_ms ?? '-'} ` +
556
+ `exit=${result.exit_code ?? '-'} signal=${result.exit_signal ?? '-'} ` +
557
+ `dur_ms=${result.exit_elapsed_ms ?? '-'} ` +
558
+ `stdout_lines=${result.stdout_lines} stderr_lines=${result.stderr_lines}`
559
+ );
560
+ if (i < opts.attempts && opts.delayBetweenMs > 0) {
561
+ await new Promise((r) => setTimeout(r, opts.delayBetweenMs));
562
+ }
563
+ }
564
+
565
+ const summary = summarize(attempts);
566
+ console.error('');
567
+ console.error(`[repro] summary: ${JSON.stringify(summary)}`);
568
+
569
+ const outPath = opts.out
570
+ ? resolve(opts.out)
571
+ : resolve(`bug-54-repro-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
572
+ const payload = {
573
+ ...header,
574
+ finished_at: new Date().toISOString(),
575
+ summary,
576
+ attempts,
577
+ };
578
+ writeFileSync(outPath, JSON.stringify(payload, null, 2) + '\n');
579
+ console.error('');
580
+ console.error(`[repro] full capture written to: ${outPath}`);
581
+ console.error('[repro] share this file in BUG-54 thread / AGENT-TALK.md');
582
+ }
583
+
584
+ function summarize(attempts) {
585
+ const classification = {};
586
+ let stdout_attached = 0;
587
+ let attached = 0;
588
+ let watchdog_fires = 0;
589
+ let spawn_errors = 0;
590
+ let process_errors = 0;
591
+ let total_first_stdout_ms = 0;
592
+ let stdout_samples = 0;
593
+ for (const a of attempts) {
594
+ classification[a.classification] = (classification[a.classification] || 0) + 1;
595
+ if (a.spawn_attached_at) attached++;
596
+ if (a.first_stdout_at) {
597
+ stdout_attached++;
598
+ total_first_stdout_ms += a.first_stdout_elapsed_ms || 0;
599
+ stdout_samples++;
600
+ }
601
+ if (a.watchdog_fired) watchdog_fires++;
602
+ if (a.spawn_error) spawn_errors++;
603
+ if (a.process_error) process_errors++;
604
+ }
605
+ return {
606
+ total: attempts.length,
607
+ spawn_attached: attached,
608
+ stdout_attached,
609
+ watchdog_fires,
610
+ spawn_errors,
611
+ process_errors,
612
+ avg_first_stdout_ms: stdout_samples > 0 ? Math.round(total_first_stdout_ms / stdout_samples) : null,
613
+ classification,
614
+ success_rate_first_stdout: attempts.length > 0
615
+ ? Number((stdout_attached / attempts.length).toFixed(3))
616
+ : 0,
617
+ };
618
+ }
619
+
620
+ main().catch((err) => {
621
+ console.error(`[repro] fatal: ${err?.stack || err?.message || err}`);
622
+ process.exit(99);
623
+ });