triflux 10.9.12 → 10.9.13

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.
@@ -0,0 +1,275 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
5
+ const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
6
+ const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
7
+ const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
8
+
9
+ function normalizeSessionId(sessionId) {
10
+ if (sessionId == null) return "";
11
+ return String(sessionId).trim();
12
+ }
13
+
14
+ function cloneSession(session) {
15
+ return {
16
+ ...session,
17
+ dirtyFiles: Array.isArray(session.dirtyFiles) ? [...session.dirtyFiles] : [],
18
+ };
19
+ }
20
+
21
+ function sanitizeSession(raw, fallbackSessionId = "") {
22
+ const sessionId = normalizeSessionId(raw?.sessionId ?? fallbackSessionId);
23
+ if (!sessionId) return null;
24
+
25
+ return {
26
+ sessionId,
27
+ host: typeof raw?.host === "string" ? raw.host : "local",
28
+ worktreePath: typeof raw?.worktreePath === "string" ? raw.worktreePath : "",
29
+ branch: typeof raw?.branch === "string" ? raw.branch : "",
30
+ dirtyFiles: Array.isArray(raw?.dirtyFiles) ? [...raw.dirtyFiles] : [],
31
+ taskSummary: typeof raw?.taskSummary === "string" ? raw.taskSummary : "",
32
+ lastHeartbeat:
33
+ typeof raw?.lastHeartbeat === "number" ? raw.lastHeartbeat : Date.now(),
34
+ status:
35
+ raw?.status === "stale" || raw?.status === "expired"
36
+ ? raw.status
37
+ : "active",
38
+ isRemote: Boolean(raw?.isRemote),
39
+ };
40
+ }
41
+
42
+ export function createSynapseRegistry(opts = {}) {
43
+ const {
44
+ persistPath,
45
+ localHeartbeatIntervalMs = DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS,
46
+ localTimeoutMs = DEFAULT_LOCAL_TIMEOUT_MS,
47
+ remoteHeartbeatIntervalMs = DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS,
48
+ remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
49
+ } = opts;
50
+
51
+ const sessions = new Map();
52
+ const monitors = new Map();
53
+ const staleCallbacks = new Set();
54
+ const removedCallbacks = new Set();
55
+
56
+ function now() {
57
+ return Date.now();
58
+ }
59
+
60
+ function intervalFor(session) {
61
+ return session.isRemote
62
+ ? remoteHeartbeatIntervalMs
63
+ : localHeartbeatIntervalMs;
64
+ }
65
+
66
+ function timeoutFor(session) {
67
+ return session.isRemote ? remoteTimeoutMs : localTimeoutMs;
68
+ }
69
+
70
+ function persist() {
71
+ if (!persistPath) return;
72
+ try {
73
+ mkdirSync(dirname(persistPath), { recursive: true });
74
+ const data = Object.fromEntries(
75
+ [...sessions].map(([k, v]) => [k, cloneSession(v)]),
76
+ );
77
+ writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
78
+ } catch {
79
+ /* best-effort */
80
+ }
81
+ }
82
+
83
+ function restore() {
84
+ if (!persistPath || !existsSync(persistPath)) return;
85
+ try {
86
+ const data = JSON.parse(readFileSync(persistPath, "utf8"));
87
+ for (const [sessionId, session] of Object.entries(data)) {
88
+ const sanitized = sanitizeSession(session, sessionId);
89
+ if (sanitized) sessions.set(sanitized.sessionId, sanitized);
90
+ }
91
+ } catch {
92
+ /* corrupted file — start fresh */
93
+ }
94
+ }
95
+
96
+ function stopMonitor(sessionId) {
97
+ const timer = monitors.get(sessionId);
98
+ if (!timer) return;
99
+ clearInterval(timer);
100
+ monitors.delete(sessionId);
101
+ }
102
+
103
+ function notifyStale(session) {
104
+ // TODO: emit synapse.session.stale via Hub deliveryEmitter
105
+ for (const callback of staleCallbacks) {
106
+ try {
107
+ callback(cloneSession(session));
108
+ } catch {
109
+ /* no-op */
110
+ }
111
+ }
112
+ }
113
+
114
+ function notifyRemoved(session) {
115
+ // TODO: emit synapse.session.removed via Hub deliveryEmitter
116
+ for (const callback of removedCallbacks) {
117
+ try {
118
+ callback(cloneSession(session));
119
+ } catch {
120
+ /* no-op */
121
+ }
122
+ }
123
+ }
124
+
125
+ function startMonitor(sessionId) {
126
+ stopMonitor(sessionId);
127
+
128
+ const session = sessions.get(sessionId);
129
+ if (!session) return;
130
+
131
+ const timer = setInterval(() => {
132
+ const current = sessions.get(sessionId);
133
+ if (!current) return;
134
+
135
+ const elapsedMs = now() - current.lastHeartbeat;
136
+ if (elapsedMs > timeoutFor(current) && current.status !== "stale") {
137
+ current.status = "stale";
138
+ notifyStale(current);
139
+ }
140
+ }, intervalFor(session));
141
+
142
+ if (typeof timer.unref === "function") timer.unref();
143
+ monitors.set(sessionId, timer);
144
+ }
145
+
146
+ restore();
147
+ for (const sessionId of sessions.keys()) {
148
+ startMonitor(sessionId);
149
+ }
150
+
151
+ function register(meta) {
152
+ const sessionId = normalizeSessionId(meta?.sessionId);
153
+ if (!sessionId || sessions.has(sessionId)) {
154
+ return { ok: false, sessionId };
155
+ }
156
+
157
+ const session = sanitizeSession(
158
+ {
159
+ ...meta,
160
+ sessionId,
161
+ status: "active",
162
+ lastHeartbeat: now(),
163
+ },
164
+ sessionId,
165
+ );
166
+
167
+ sessions.set(sessionId, session);
168
+ startMonitor(sessionId);
169
+ persist();
170
+
171
+ // TODO: emit synapse.session.started via Hub deliveryEmitter
172
+ return { ok: true, sessionId };
173
+ }
174
+
175
+ function unregister(sessionId) {
176
+ const normalized = normalizeSessionId(sessionId);
177
+ const session = sessions.get(normalized);
178
+ if (!session) return false;
179
+
180
+ stopMonitor(normalized);
181
+ sessions.delete(normalized);
182
+ persist();
183
+ notifyRemoved(session);
184
+ return true;
185
+ }
186
+
187
+ function heartbeat(sessionId, partialMeta = null) {
188
+ const normalized = normalizeSessionId(sessionId);
189
+ const session = sessions.get(normalized);
190
+ if (!session) return false;
191
+
192
+ const wasRemote = session.isRemote;
193
+
194
+ session.lastHeartbeat = now();
195
+ session.status = "active";
196
+
197
+ if (partialMeta && typeof partialMeta === "object") {
198
+ if (typeof partialMeta.host === "string") session.host = partialMeta.host;
199
+ if (typeof partialMeta.worktreePath === "string") {
200
+ session.worktreePath = partialMeta.worktreePath;
201
+ }
202
+ if (typeof partialMeta.branch === "string") session.branch = partialMeta.branch;
203
+ if (Array.isArray(partialMeta.dirtyFiles)) {
204
+ session.dirtyFiles = [...partialMeta.dirtyFiles];
205
+ }
206
+ if (typeof partialMeta.taskSummary === "string") {
207
+ session.taskSummary = partialMeta.taskSummary;
208
+ }
209
+ if (typeof partialMeta.isRemote === "boolean") {
210
+ session.isRemote = partialMeta.isRemote;
211
+ }
212
+ }
213
+
214
+ if (session.isRemote !== wasRemote) {
215
+ startMonitor(normalized);
216
+ }
217
+
218
+ persist();
219
+ // TODO: emit synapse.session.heartbeat via Hub deliveryEmitter
220
+ return true;
221
+ }
222
+
223
+ function getActive() {
224
+ return [...sessions.values()]
225
+ .filter((session) => session.status === "active")
226
+ .map((session) => cloneSession(session));
227
+ }
228
+
229
+ function getAll() {
230
+ return [...sessions.values()].map((session) => cloneSession(session));
231
+ }
232
+
233
+ function getSession(sessionId) {
234
+ const normalized = normalizeSessionId(sessionId);
235
+ if (!normalized) return null;
236
+ const session = sessions.get(normalized);
237
+ return session ? cloneSession(session) : null;
238
+ }
239
+
240
+ function onStale(callback) {
241
+ if (typeof callback !== "function") return;
242
+ staleCallbacks.add(callback);
243
+ }
244
+
245
+ function onRemoved(callback) {
246
+ if (typeof callback !== "function") return;
247
+ removedCallbacks.add(callback);
248
+ }
249
+
250
+ function snapshot() {
251
+ return {
252
+ sessions: getAll(),
253
+ };
254
+ }
255
+
256
+ function destroy() {
257
+ for (const sessionId of monitors.keys()) {
258
+ stopMonitor(sessionId);
259
+ }
260
+ persist();
261
+ }
262
+
263
+ return Object.freeze({
264
+ register,
265
+ unregister,
266
+ heartbeat,
267
+ getActive,
268
+ getAll,
269
+ getSession,
270
+ onStale,
271
+ onRemoved,
272
+ snapshot,
273
+ destroy,
274
+ });
275
+ }
@@ -170,17 +170,42 @@ export async function prepareIntegrationBranch({
170
170
  * Rebase a shard branch onto the integration branch.
171
171
  * Uses rebase + fast-forward only (no merge commits).
172
172
  *
173
+ * Synapse v1: when a `preflight` checker + `sessionContext` are provided,
174
+ * the rebase is pre-flighted against active sessions' dirty files/leases.
175
+ * Blocked rebases return `{ ok: false, preflight: decision }` without touching
176
+ * any branch. If preflight is omitted, behavior is unchanged.
177
+ *
173
178
  * @param {object} opts
174
179
  * @param {string} opts.shardBranch — the shard's branch name
175
180
  * @param {string} opts.integrationBranch — target integration branch
176
181
  * @param {string} [opts.rootDir=process.cwd()]
177
- * @returns {Promise<{ ok: boolean, headCommit?: string, error?: string }>}
182
+ * @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
183
+ * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
184
+ * @returns {Promise<{ ok: boolean, headCommit?: string, error?: string, preflight?: object }>}
178
185
  */
179
186
  export async function rebaseShardOntoIntegration({
180
187
  shardBranch,
181
188
  integrationBranch,
182
189
  rootDir = process.cwd(),
190
+ preflight = null,
191
+ sessionContext = null,
183
192
  }) {
193
+ // Synapse v1 pre-flight: bail out before mutating branches when another
194
+ // session claims overlapping files.
195
+ if (preflight && sessionContext) {
196
+ const decision = preflight.checkRebase(
197
+ { branch: integrationBranch },
198
+ sessionContext,
199
+ );
200
+ if (!decision.allowed) {
201
+ return {
202
+ ok: false,
203
+ error: `git-preflight blocked rebase: ${decision.reason || "overlap"}`,
204
+ preflight: decision,
205
+ };
206
+ }
207
+ }
208
+
184
209
  // Backup integration HEAD for rollback
185
210
  const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
186
211
 
@@ -220,18 +245,40 @@ export async function rebaseShardOntoIntegration({
220
245
  * Remove a worktree and its branch.
221
246
  * Follows WT race-guard: sleep between operations.
222
247
  *
248
+ * Synapse v1: when `preflight` + `sessionContext` are provided, the removal
249
+ * is pre-flighted against active sessions — blocked if another session is
250
+ * still registered to this worktree path.
251
+ *
223
252
  * @param {object} opts
224
253
  * @param {string} opts.worktreePath
225
254
  * @param {string} [opts.branchName] — optional branch to delete
226
255
  * @param {string} [opts.rootDir=process.cwd()]
227
256
  * @param {boolean} [opts.force=false]
257
+ * @param {{ checkWorktreeRemove: Function } | null} [opts.preflight=null]
258
+ * @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
228
259
  */
229
260
  export async function pruneWorktree({
230
261
  worktreePath,
231
262
  branchName,
232
263
  rootDir = process.cwd(),
233
264
  force = false,
265
+ preflight = null,
266
+ sessionContext = null,
234
267
  }) {
268
+ if (preflight && sessionContext) {
269
+ const decision = preflight.checkWorktreeRemove(
270
+ { worktreePath },
271
+ sessionContext,
272
+ );
273
+ if (!decision.allowed) {
274
+ return {
275
+ ok: false,
276
+ error: `git-preflight blocked worktree-remove: ${decision.reason || "active_worktree"}`,
277
+ preflight: decision,
278
+ };
279
+ }
280
+ }
281
+
235
282
  const forceFlag = force ? "--force" : "";
236
283
 
237
284
  // Remove worktree (with retry for Windows file handle issues — E5)
@@ -349,7 +349,11 @@ export class GeminiWorker {
349
349
  }
350
350
 
351
351
  if (exitCode !== 0) {
352
- throw createWorkerError(`Gemini worker exited with code ${exitCode}`, {
352
+ // Build a descriptive message when stderr is empty to aid debugging
353
+ const errMsg = result.stderr
354
+ ? `Gemini worker exited with code ${exitCode}`
355
+ : `Gemini worker exited with code ${exitCode} (stderr empty, signal=${exitSignal ?? "none"}, events=${events.length}, stdout=${result.stdout.length}B)`;
356
+ throw createWorkerError(errMsg, {
353
357
  code: "WORKER_EXIT",
354
358
  result,
355
359
  stderr: result.stderr,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.12",
3
+ "version": "10.9.13",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,15 @@ describe("doctor-diagnose: 진단 번들 생성", () => {
39
39
  assert.equal(typeof result.hookTimingCount, "number");
40
40
  });
41
41
 
42
+ it("codexMcpApproval 진단 결과 포함", () => {
43
+ assert.ok(result.codexMcpApproval, "codexMcpApproval 필드 누락");
44
+ assert.equal(typeof result.codexMcpApproval.found, "boolean");
45
+ if (result.codexMcpApproval.found) {
46
+ assert.ok(Array.isArray(result.codexMcpApproval.tools));
47
+ assert.ok(result.codexMcpApproval.tools.length > 0);
48
+ }
49
+ });
50
+
42
51
  after(() => {
43
52
  // 테스트 생성 zip 정리
44
53
  if (result?.zipPath && existsSync(result.zipPath)) {
@@ -18,6 +18,7 @@ import { join } from "node:path";
18
18
  const TRIFLUX_DIR = join(homedir(), ".triflux");
19
19
  const LOGS_DIR = join(TRIFLUX_DIR, "logs");
20
20
  const DIAG_DIR = join(TRIFLUX_DIR, "diagnostics");
21
+ const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
21
22
  const ONE_HOUR_MS = 60 * 60 * 1000;
22
23
 
23
24
  function collectSpawnTraces(cutoffMs = ONE_HOUR_MS) {
@@ -85,6 +86,47 @@ function computeSpawnStats(traces) {
85
86
  };
86
87
  }
87
88
 
89
+ /**
90
+ * ~/.codex/config.toml에서 [mcp_servers.*.tools.*] 섹션 내부의
91
+ * approval_mode = "approve"를 감지한다. 이 값이 있으면 codex exec가
92
+ * non-TTY subprocess에서 승인 대기로 stall한다.
93
+ * refs: tellang/triflux#66, Yeachan-Heo/oh-my-codex#1478
94
+ */
95
+ function checkCodexMcpApproval() {
96
+ if (!existsSync(CODEX_CONFIG_PATH)) {
97
+ return { found: false, reason: "config.toml not found" };
98
+ }
99
+
100
+ let content;
101
+ try {
102
+ content = readFileSync(CODEX_CONFIG_PATH, "utf8");
103
+ } catch {
104
+ return { found: false, reason: "config.toml unreadable" };
105
+ }
106
+
107
+ const badTools = [];
108
+ let inMcpTool = false;
109
+ let currentSection = "";
110
+
111
+ for (const line of content.split("\n")) {
112
+ const sectionMatch = line.match(/^\[(.+)\]/);
113
+ if (sectionMatch) {
114
+ currentSection = sectionMatch[1];
115
+ inMcpTool = /^mcp_servers\..+\.tools\./.test(currentSection);
116
+ continue;
117
+ }
118
+ if (inMcpTool && /^\s*approval_mode\s*=\s*"approve"/.test(line)) {
119
+ badTools.push(currentSection);
120
+ }
121
+ }
122
+
123
+ return {
124
+ found: badTools.length > 0,
125
+ tools: badTools,
126
+ fix: 'sed -i \'s/approval_mode = "approve"/approval_mode = "auto"/g\' ~/.codex/config.toml',
127
+ };
128
+ }
129
+
88
130
  function collectSystemInfo() {
89
131
  const info = {
90
132
  platform: platform(),
@@ -165,9 +207,28 @@ function generateSummary(stats, sysInfo, hookTimings, traceCount) {
165
207
  `blocked: ${stats.blocked}`,
166
208
  `trace entries: ${traceCount}`,
167
209
  "",
168
- "--- Hook Timings (last 1h) ---",
210
+ "--- Codex MCP Approval Check ---",
169
211
  ];
170
212
 
213
+ const mcpCheck = checkCodexMcpApproval();
214
+ if (!mcpCheck.found && mcpCheck.reason) {
215
+ lines.push(` ${mcpCheck.reason}`);
216
+ } else if (mcpCheck.found) {
217
+ lines.push(` ⚠ ${mcpCheck.tools.length}개 MCP tool에 approval_mode="approve" 감지`);
218
+ lines.push(` codex exec non-TTY stall 위험 (refs: triflux#66)`);
219
+ for (const tool of mcpCheck.tools) {
220
+ lines.push(` - [${tool}]`);
221
+ }
222
+ lines.push(` fix: ${mcpCheck.fix}`);
223
+ } else {
224
+ lines.push(" ✔ MCP tool approval_mode 정상");
225
+ }
226
+
227
+ lines.push(
228
+ "",
229
+ "--- Hook Timings (last 1h) ---",
230
+ );
231
+
171
232
  if (hookTimings.length === 0) {
172
233
  lines.push("no hook timing data found");
173
234
  } else {
@@ -248,6 +309,8 @@ export async function diagnose({ json = false } = {}) {
248
309
  rmSync(bundleDir, { recursive: true, force: true });
249
310
  } catch { /* leave it */ }
250
311
 
312
+ const mcpApprovalCheck = checkCodexMcpApproval();
313
+
251
314
  const result = {
252
315
  ok: true,
253
316
  zipPath,
@@ -255,6 +318,7 @@ export async function diagnose({ json = false } = {}) {
255
318
  sysInfo,
256
319
  traceCount: traces.length,
257
320
  hookTimingCount: hookTimings.length,
321
+ codexMcpApproval: mcpApprovalCheck,
258
322
  };
259
323
 
260
324
  if (json) {
@@ -30,6 +30,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
30
30
  import { tmpdir } from "node:os";
31
31
  import { join } from "node:path";
32
32
  import { deny, nudge } from "./lib/hook-utils.mjs";
33
+ import { isProcessAlive } from "./lib/process-utils.mjs";
33
34
  import { probePsmuxSupport } from "./lib/psmux-info.mjs";
34
35
 
35
36
  const CACHE_FILE = join(tmpdir(), "tfx-psmux-check.json");
@@ -41,16 +42,6 @@ const MULTI_EXPIRE_MS = 30 * 60 * 1000; // 30분 자동 만료
41
42
  const GATE_THRESHOLD = 2; // A: dispatch 전 허용할 Agent 호출 수
42
43
  const NUDGE_THRESHOLD = 4; // B: dispatch 후 nudge 트리거 횟수
43
44
 
44
- function isOwnerAlive(pid) {
45
- if (!pid || typeof pid !== "number") return false;
46
- try {
47
- process.kill(pid, 0);
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- }
53
-
54
45
  function readMultiState() {
55
46
  try {
56
47
  if (!existsSync(MULTI_STATE_FILE)) return null;
@@ -66,7 +57,7 @@ function readMultiState() {
66
57
  return null;
67
58
  }
68
59
  // ownerPid 생존 체크 — 소유 세션이 죽었으면 stale (#62)
69
- if (state.ownerPid && !isOwnerAlive(state.ownerPid)) {
60
+ if (state.ownerPid && !isProcessAlive(state.ownerPid)) {
70
61
  try {
71
62
  unlinkSync(MULTI_STATE_FILE);
72
63
  } catch {
@@ -0,0 +1,26 @@
1
+ // scripts/lib/process-utils.mjs
2
+ // 프로세스 관련 공유 유틸리티 (훅 및 lightweight 스크립트용)
3
+ //
4
+ // Note: hub/lib/process-utils.mjs에는 더 포괄적인 orphan cleanup 로직이 있습니다.
5
+ // 이 파일은 단순 alive 체크만 필요한 훅/스크립트용 (상위 의존성 없이 동작).
6
+
7
+ /**
8
+ * 주어진 PID의 프로세스가 살아있는지 확인합니다.
9
+ * - EPERM: 프로세스는 존재하지만 signal 권한 없음 → alive
10
+ * - ESRCH: 프로세스가 존재하지 않음 → dead
11
+ * - 기타 에러 (invalid pid 포함): dead로 간주
12
+ *
13
+ * @param {number|string} pid
14
+ * @returns {boolean}
15
+ */
16
+ export function isProcessAlive(pid) {
17
+ const n = Number(pid);
18
+ if (!Number.isInteger(n) || n <= 0) return false;
19
+ try {
20
+ process.kill(n, 0);
21
+ return true;
22
+ } catch (e) {
23
+ if (e?.code === "EPERM") return true;
24
+ return false;
25
+ }
26
+ }
@@ -20,20 +20,12 @@ import {
20
20
  } from "node:fs";
21
21
  import { platform, tmpdir } from "node:os";
22
22
  import { join } from "node:path";
23
+ import { isProcessAlive } from "./lib/process-utils.mjs";
23
24
 
24
25
  const MULTI_STATE_FILE = join(tmpdir(), "tfx-multi-state.json");
25
26
  const EXPIRE_MS = 30 * 60 * 1000; // 30분
26
27
  const PID_FILE_RE = /^tfx-route-(\d+)-pids$/;
27
28
 
28
- function isProcessAlive(pid) {
29
- try {
30
- process.kill(pid, 0);
31
- return true;
32
- } catch {
33
- return false;
34
- }
35
- }
36
-
37
29
  function treeKill(pid) {
38
30
  try {
39
31
  if (platform() === "win32") {
@@ -222,11 +222,31 @@ try {
222
222
  if (!result.response.endsWith("\n")) process.stdout.write("\n");
223
223
  }
224
224
  } catch (error) {
225
+ // Always emit error.message first for quick identification
226
+ process.stderr.write(`[tfx-route-worker] ${error.message}\n`);
227
+
228
+ // Emit captured stderr from the child process (may be empty)
225
229
  if (error.stderr) {
226
230
  process.stderr.write(String(error.stderr));
227
231
  if (!String(error.stderr).endsWith("\n")) process.stderr.write("\n");
228
232
  }
229
- process.stderr.write(`${error.message}\n`);
233
+
234
+ // When stderr is empty, surface diagnostic details from error.result
235
+ // so the caller can debug silent failures
236
+ if (!error.stderr && error.result) {
237
+ const r = error.result;
238
+ const diag = [
239
+ `[tfx-route-worker] diagnostics: exitCode=${r.exitCode ?? "null"} signal=${r.exitSignal ?? "none"} timedOut=${r.timedOut ?? false}`,
240
+ r.events?.length
241
+ ? `[tfx-route-worker] events(${r.events.length}): ${JSON.stringify(r.events.slice(-3))}`
242
+ : "[tfx-route-worker] events: none",
243
+ r.stdout
244
+ ? `[tfx-route-worker] child stdout(${r.stdout.length}B): ${r.stdout.slice(0, 512)}`
245
+ : "[tfx-route-worker] child stdout: empty",
246
+ ];
247
+ process.stderr.write(diag.join("\n") + "\n");
248
+ }
249
+
230
250
  process.exitCode = error.code === "ETIMEDOUT" ? 124 : 1;
231
251
  } finally {
232
252
  try {