@exaudeus/workrail 3.26.1 → 3.27.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.
@@ -35,17 +35,26 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_BRIDGE_CONFIG = void 0;
37
37
  exports.detectHealthyPrimary = detectHealthyPrimary;
38
+ exports.spawnLockPath = spawnLockPath;
39
+ exports.acquireSpawnLock = acquireSpawnLock;
40
+ exports.releaseSpawnLock = releaseSpawnLock;
38
41
  exports.spawnPrimary = spawnPrimary;
39
42
  exports.reconnectWithBackoff = reconnectWithBackoff;
40
43
  exports.handleReconnectOutcome = handleReconnectOutcome;
41
44
  exports.startBridgeServer = startBridgeServer;
42
45
  const fatal_exit_js_1 = require("./fatal-exit.js");
43
46
  const bridge_events_js_1 = require("./bridge-events.js");
47
+ const primary_tombstone_js_1 = require("./primary-tombstone.js");
48
+ const fs_1 = require("fs");
49
+ const os_1 = require("os");
50
+ const path_1 = require("path");
44
51
  exports.DEFAULT_BRIDGE_CONFIG = {
45
52
  reconnectBaseDelayMs: 250,
46
53
  reconnectMaxAttempts: 8,
47
54
  forwardTimeoutMs: 30000,
48
55
  maxRespawnAttempts: 3,
56
+ spawnLockStaleMs: 30000,
57
+ waitForPrimaryPollMs: 5000,
49
58
  };
50
59
  async function detectHealthyPrimary(port, opts = {}) {
51
60
  const retries = opts.retries ?? 3;
@@ -59,8 +68,10 @@ async function detectHealthyPrimary(port, opts = {}) {
59
68
  });
60
69
  if (response.ok) {
61
70
  const body = (await response.json().catch(() => null));
62
- if (body?.service === 'workrail')
63
- return port;
71
+ if (body?.service === 'workrail') {
72
+ const pid = typeof body.pid === 'number' ? body.pid : 0;
73
+ return { port, pid };
74
+ }
64
75
  }
65
76
  }
66
77
  catch {
@@ -71,10 +82,71 @@ async function detectHealthyPrimary(port, opts = {}) {
71
82
  }
72
83
  return null;
73
84
  }
85
+ function spawnLockPath(port) {
86
+ return (0, path_1.join)((0, os_1.homedir)(), '.workrail', `spawn-coordinator-${port}.lock`);
87
+ }
88
+ function acquireSpawnLock(port, staleMs, deps = {}) {
89
+ const lockPath = spawnLockPath(port);
90
+ const writeFn = deps.writeFileSync ?? fs_1.writeFileSync;
91
+ const statFn = deps.statSync ?? require('fs').statSync;
92
+ const unlinkFn = deps.unlinkSync ?? require('fs').unlinkSync;
93
+ try {
94
+ (0, fs_1.mkdirSync)((0, path_1.join)((0, os_1.homedir)(), '.workrail'), { recursive: true });
95
+ }
96
+ catch {
97
+ return { kind: 'skipped', reason: 'mkdirSync failed' };
98
+ }
99
+ try {
100
+ writeFn(lockPath, String(process.pid), { flag: 'wx' });
101
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_acquired', port });
102
+ return { kind: 'acquired' };
103
+ }
104
+ catch (err) {
105
+ const code = err.code;
106
+ if (code === 'EEXIST') {
107
+ try {
108
+ const stat = statFn(lockPath);
109
+ const ageMs = Date.now() - stat.mtimeMs;
110
+ if (ageMs > staleMs) {
111
+ try {
112
+ unlinkFn(lockPath);
113
+ }
114
+ catch {
115
+ }
116
+ try {
117
+ writeFn(lockPath, String(process.pid), { flag: 'wx' });
118
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_acquired', port });
119
+ return { kind: 'acquired' };
120
+ }
121
+ catch {
122
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: 'lost race after stale reclaim' });
123
+ return { kind: 'skipped', reason: 'lost race after stale reclaim' };
124
+ }
125
+ }
126
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: 'lock held by another bridge' });
127
+ return { kind: 'skipped', reason: 'lock held by another bridge' };
128
+ }
129
+ catch {
130
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: 'lock contested' });
131
+ return { kind: 'skipped', reason: 'lock contested' };
132
+ }
133
+ }
134
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_lock_skipped', reason: `unexpected error: ${code ?? String(err)}` });
135
+ return { kind: 'skipped', reason: `unexpected error: ${code ?? String(err)}` };
136
+ }
137
+ }
138
+ function releaseSpawnLock(port, deps = {}) {
139
+ const unlinkFn = deps.unlinkSync ?? require('fs').unlinkSync;
140
+ try {
141
+ unlinkFn(spawnLockPath(port));
142
+ }
143
+ catch {
144
+ }
145
+ }
74
146
  async function spawnPrimary(port, deps) {
75
- await sleep(Math.random() * 300);
76
- const alreadyUp = await detectHealthyPrimary(port, { retries: 1, fetch: deps.fetch });
77
- if (alreadyUp != null) {
147
+ await sleep(Math.random() * 2000);
148
+ const primaryDetected = await detectHealthyPrimary(port, { retries: 3, baseDelayMs: 500, fetch: deps.fetch });
149
+ if (primaryDetected != null) {
78
150
  (0, bridge_events_js_1.logBridgeEvent)({ kind: 'spawn_skipped', reason: 'primary already up after jitter' });
79
151
  console.error('[Bridge] Primary already available after jitter — skipping spawn');
80
152
  return;
@@ -140,9 +212,14 @@ async function handleReconnectOutcome(outcome, reconnectingState, deps) {
140
212
  deps.startReconnectLoop();
141
213
  }
142
214
  else {
143
- (0, bridge_events_js_1.logBridgeEvent)({ kind: 'budget_exhausted', budgetUsed: reconnectingState.maxAttempts });
144
- deps.setConnectionState({ kind: 'closed' });
145
- deps.performShutdown('respawn budget exhausted — primary repeatedly unavailable');
215
+ (0, bridge_events_js_1.logBridgeEvent)({
216
+ kind: 'budget_exhausted',
217
+ budgetUsed: reconnectingState.maxAttempts,
218
+ respawnBudget: reconnectingState.respawnBudget,
219
+ });
220
+ console.error('[Bridge] Spawn budget exhausted — entering slow-poll mode (5s interval)');
221
+ deps.setConnectionState({ kind: 'waiting_for_primary' });
222
+ deps.startWaitLoop();
146
223
  }
147
224
  return;
148
225
  }
@@ -183,10 +260,23 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
183
260
  if (shutdownSignal.aborted)
184
261
  return;
185
262
  const current = connectionState;
186
- if (current.kind === 'connecting' || current.kind === 'reconnecting')
263
+ if (current.kind === 'connecting' ||
264
+ current.kind === 'reconnecting' ||
265
+ current.kind === 'waiting_for_primary')
187
266
  return;
188
267
  (0, bridge_events_js_1.logBridgeEvent)({ kind: 'disconnected' });
189
268
  console.error('[Bridge] Primary connection lost — reconnecting');
269
+ const connectedPrimaryPid = deps.originalPrimaryPid ?? 0;
270
+ if (connectedPrimaryPid > 0) {
271
+ const tombstone = (0, primary_tombstone_js_1.readTombstone)();
272
+ if (tombstone?.pid === connectedPrimaryPid) {
273
+ console.error('[Bridge] Tombstone detected — primary died cleanly, entering slow-poll mode');
274
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'waiting_for_primary', port: primaryPort });
275
+ setConnectionState({ kind: 'waiting_for_primary' });
276
+ startWaitLoop();
277
+ return;
278
+ }
279
+ }
190
280
  setConnectionState({
191
281
  kind: 'reconnecting',
192
282
  attempt: 0,
@@ -213,6 +303,17 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
213
303
  signal: shutdownSignal,
214
304
  config,
215
305
  detect: async (attempt) => {
306
+ const connectedPrimaryPid = deps.originalPrimaryPid ?? 0;
307
+ if (connectedPrimaryPid > 0) {
308
+ const tombstone = (0, primary_tombstone_js_1.readTombstone)();
309
+ if (tombstone?.pid === connectedPrimaryPid) {
310
+ console.error('[Bridge] Tombstone detected during reconnect — entering slow-poll mode');
311
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'waiting_for_primary', port: primaryPort });
312
+ setConnectionState({ kind: 'waiting_for_primary' });
313
+ startWaitLoop();
314
+ return true;
315
+ }
316
+ }
216
317
  (0, bridge_events_js_1.logBridgeEvent)({ kind: 'reconnect_attempt', attempt: attempt + 1, maxAttempts: config.reconnectMaxAttempts });
217
318
  console.error(`[Bridge] Reconnect attempt ${attempt + 1}/${config.reconnectMaxAttempts}`);
218
319
  const detected = await detectHealthyPrimary(primaryPort, { retries: 1, fetch: deps.fetch });
@@ -230,7 +331,20 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
230
331
  setConnectionState,
231
332
  performShutdown,
232
333
  startReconnectLoop,
233
- triggerSpawn: () => spawnPrimary(primaryPort, { spawn: spawnFn, fetch: deps.fetch }),
334
+ startWaitLoop,
335
+ triggerSpawn: async () => {
336
+ const lockResult = acquireSpawnLock(primaryPort, config.spawnLockStaleMs);
337
+ if (lockResult.kind === 'skipped') {
338
+ console.error(`[Bridge] Spawn skipped (coordinator lock): ${lockResult.reason}`);
339
+ return;
340
+ }
341
+ try {
342
+ await spawnPrimary(primaryPort, { spawn: spawnFn, fetch: deps.fetch });
343
+ }
344
+ finally {
345
+ releaseSpawnLock(primaryPort);
346
+ }
347
+ },
234
348
  config,
235
349
  });
236
350
  })
@@ -244,6 +358,40 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
244
358
  console.error('[Bridge] Unexpected error in reconnect loop:', err);
245
359
  });
246
360
  };
361
+ const startWaitLoop = () => {
362
+ const stateAtStart = connectionState;
363
+ if (stateAtStart.kind !== 'waiting_for_primary')
364
+ return;
365
+ void (async () => {
366
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'waiting_for_primary', port: primaryPort });
367
+ while (!shutdownSignal.aborted) {
368
+ await sleep(config.waitForPrimaryPollMs);
369
+ if (shutdownSignal.aborted)
370
+ return;
371
+ const detected = await detectHealthyPrimary(primaryPort, { retries: 1, fetch: deps.fetch });
372
+ if (detected != null) {
373
+ console.error('[Bridge] Primary found after waiting — resuming normal operation');
374
+ (0, bridge_events_js_1.logBridgeEvent)({ kind: 'primary_found_after_wait', port: primaryPort });
375
+ setConnectionState({
376
+ kind: 'reconnecting',
377
+ attempt: 0,
378
+ maxAttempts: config.reconnectMaxAttempts,
379
+ respawnBudget: config.maxRespawnAttempts,
380
+ });
381
+ startReconnectLoop();
382
+ return;
383
+ }
384
+ }
385
+ })().catch((err) => {
386
+ const errObj = err instanceof Error ? err : new Error(String(err));
387
+ (0, bridge_events_js_1.logBridgeEvent)({
388
+ kind: 'reconnect_loop_error',
389
+ message: `wait loop error: ${errObj.message}`,
390
+ stack: errObj.stack ?? null,
391
+ });
392
+ console.error('[Bridge] Unexpected error in wait loop:', err);
393
+ });
394
+ };
247
395
  stdioTransport.onmessage = (msg) => {
248
396
  const state = connectionState;
249
397
  switch (state.kind) {
@@ -259,6 +407,7 @@ async function startBridgeServer(primaryPort, config = exports.DEFAULT_BRIDGE_CO
259
407
  }
260
408
  case 'connecting':
261
409
  case 'reconnecting':
410
+ case 'waiting_for_primary':
262
411
  sendUnavailableError(msg, (m) => stdioTransport.send(m));
263
412
  return;
264
413
  case 'closed':
@@ -20,9 +20,22 @@ export type BridgeEvent = {
20
20
  } | {
21
21
  readonly kind: 'spawn_skipped';
22
22
  readonly reason: string;
23
+ } | {
24
+ readonly kind: 'spawn_lock_acquired';
25
+ readonly port: number;
26
+ } | {
27
+ readonly kind: 'spawn_lock_skipped';
28
+ readonly reason: string;
23
29
  } | {
24
30
  readonly kind: 'budget_exhausted';
25
31
  readonly budgetUsed: number;
32
+ readonly respawnBudget: number;
33
+ } | {
34
+ readonly kind: 'waiting_for_primary';
35
+ readonly port: number;
36
+ } | {
37
+ readonly kind: 'primary_found_after_wait';
38
+ readonly port: number;
26
39
  } | {
27
40
  readonly kind: 'reconnect_loop_error';
28
41
  readonly message: string;
@@ -30,5 +43,9 @@ export type BridgeEvent = {
30
43
  } | {
31
44
  readonly kind: 'shutdown';
32
45
  readonly reason: string;
46
+ } | {
47
+ readonly kind: 'orphaned';
48
+ readonly expectedPid: number;
49
+ readonly actualPid: number;
33
50
  };
34
51
  export declare function logBridgeEvent(event: BridgeEvent): void;
@@ -1,5 +1,6 @@
1
1
  export type TransportKind = 'stdio' | 'http' | 'bridge';
2
2
  export declare function formatFatal(reason: unknown): string;
3
+ export declare function registerGracefulShutdown(fn: (() => Promise<void>) | null, timeoutMs?: number): void;
3
4
  export declare function fatalExit(label: string, reason: unknown): void;
4
5
  export declare function registerFatalHandlers(transport: TransportKind): void;
5
6
  export declare function logStartup(transport: TransportKind, extra?: Record<string, string | number>): void;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatFatal = formatFatal;
4
+ exports.registerGracefulShutdown = registerGracefulShutdown;
4
5
  exports.fatalExit = fatalExit;
5
6
  exports.registerFatalHandlers = registerFatalHandlers;
6
7
  exports.logStartup = logStartup;
@@ -10,6 +11,8 @@ const path_1 = require("path");
10
11
  const fatalHandlerActive = { value: false };
11
12
  let registeredTransport = null;
12
13
  const startedAtMs = Date.now();
14
+ let gracefulShutdownFn = null;
15
+ let gracefulShutdownTimeoutMs = 3000;
13
16
  const CRASH_LOG_PATH = (0, path_1.join)((0, os_1.homedir)(), '.workrail', 'crash.log');
14
17
  const CRASH_LOG_MAX_BYTES = 512 * 1024;
15
18
  function writeCrashLog(label, reason) {
@@ -46,6 +49,10 @@ function formatFatal(reason) {
46
49
  }
47
50
  return String(reason);
48
51
  }
52
+ function registerGracefulShutdown(fn, timeoutMs = 3000) {
53
+ gracefulShutdownFn = fn;
54
+ gracefulShutdownTimeoutMs = timeoutMs;
55
+ }
49
56
  function fatalExit(label, reason) {
50
57
  if (fatalHandlerActive.value)
51
58
  return;
@@ -56,7 +63,26 @@ function fatalExit(label, reason) {
56
63
  }
57
64
  catch {
58
65
  }
59
- process.exit(1);
66
+ if (gracefulShutdownFn !== null) {
67
+ const fn = gracefulShutdownFn;
68
+ const timeout = gracefulShutdownTimeoutMs;
69
+ try {
70
+ process.stderr.write(`[FatalExit] Attempting graceful shutdown (${timeout}ms timeout)\n`);
71
+ }
72
+ catch {
73
+ }
74
+ const hardExit = setTimeout(() => process.exit(1), timeout);
75
+ void Promise.resolve()
76
+ .then(() => fn())
77
+ .catch(() => { })
78
+ .finally(() => {
79
+ clearTimeout(hardExit);
80
+ process.exit(1);
81
+ });
82
+ }
83
+ else {
84
+ process.exit(1);
85
+ }
60
86
  }
61
87
  function registerFatalHandlers(transport) {
62
88
  registeredTransport = transport;
@@ -41,15 +41,21 @@ const server_js_1 = require("../server.js");
41
41
  const http_listener_js_1 = require("./http-listener.js");
42
42
  const shutdown_hooks_js_1 = require("./shutdown-hooks.js");
43
43
  const fatal_exit_js_1 = require("./fatal-exit.js");
44
+ const primary_tombstone_js_1 = require("./primary-tombstone.js");
44
45
  const crypto = __importStar(require("crypto"));
45
46
  const express_1 = __importDefault(require("express"));
46
47
  const HTTP_PORT_SCAN_END = 3199;
47
48
  async function startHttpServer(port) {
48
49
  (0, fatal_exit_js_1.registerFatalHandlers)('http');
49
50
  (0, fatal_exit_js_1.logStartup)('http', { port });
51
+ (0, primary_tombstone_js_1.clearTombstone)();
50
52
  const { server, ctx } = await (0, server_js_1.composeServer)();
51
53
  const scanEnd = Math.max(port, HTTP_PORT_SCAN_END);
52
54
  const listener = await (0, http_listener_js_1.bindWithPortFallback)(port, scanEnd);
55
+ (0, fatal_exit_js_1.registerGracefulShutdown)(async () => {
56
+ await listener.stop();
57
+ await ctx.httpServer?.stop();
58
+ });
53
59
  const { StreamableHTTPServerTransport } = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/streamableHttp.js')));
54
60
  const transport = new StreamableHTTPServerTransport({
55
61
  sessionIdGenerator: () => crypto.randomUUID(),
@@ -61,13 +67,16 @@ async function startHttpServer(port) {
61
67
  listener.app.delete('/mcp', (req, res) => transport.handleRequest(req, res));
62
68
  await server.connect(transport);
63
69
  listener.app.get('/workrail-health', (_req, res) => {
64
- res.json({ service: 'workrail' });
70
+ res.json({ service: 'workrail', pid: process.pid });
65
71
  });
66
72
  const boundPort = listener.getBoundPort();
67
73
  console.error('[Transport] WorkRail MCP Server running on HTTP');
68
74
  console.error(`[Transport] MCP endpoint: http://localhost:${boundPort}/mcp`);
69
75
  (0, shutdown_hooks_js_1.wireShutdownHooks)({
70
76
  onBeforeTerminate: async () => {
77
+ if (boundPort != null) {
78
+ (0, primary_tombstone_js_1.writeTombstone)(boundPort, process.pid);
79
+ }
71
80
  await listener.stop();
72
81
  await ctx.httpServer?.stop();
73
82
  },
@@ -0,0 +1,21 @@
1
+ export interface PrimaryTombstone {
2
+ readonly pid: number;
3
+ readonly port: number;
4
+ readonly diedAt: string;
5
+ }
6
+ export type WriteSyncLike = (path: string, content: string) => void;
7
+ export type ReadSyncLike = (path: string, encoding: 'utf-8') => string;
8
+ export type UnlinkSyncLike = (path: string) => void;
9
+ export type MkdirSyncLike = (path: string, opts: {
10
+ recursive: true;
11
+ }) => void;
12
+ export interface TombstoneDeps {
13
+ readonly writeSync?: WriteSyncLike;
14
+ readonly readSync?: ReadSyncLike;
15
+ readonly unlinkSync?: UnlinkSyncLike;
16
+ readonly mkdirSync?: MkdirSyncLike;
17
+ }
18
+ export declare function tombstonePath(): string;
19
+ export declare function writeTombstone(port: number, pid: number, deps?: TombstoneDeps): void;
20
+ export declare function readTombstone(deps?: TombstoneDeps): PrimaryTombstone | null;
21
+ export declare function clearTombstone(deps?: TombstoneDeps): void;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tombstonePath = tombstonePath;
4
+ exports.writeTombstone = writeTombstone;
5
+ exports.readTombstone = readTombstone;
6
+ exports.clearTombstone = clearTombstone;
7
+ const fs_1 = require("fs");
8
+ const os_1 = require("os");
9
+ const path_1 = require("path");
10
+ function tombstonePath() {
11
+ return (0, path_1.join)((0, os_1.homedir)(), '.workrail', 'primary.tombstone');
12
+ }
13
+ function writeTombstone(port, pid, deps = {}) {
14
+ try {
15
+ const mkdirFn = deps.mkdirSync ?? fs_1.mkdirSync;
16
+ mkdirFn((0, path_1.join)((0, os_1.homedir)(), '.workrail'), { recursive: true });
17
+ const writeFn = deps.writeSync ?? fs_1.writeFileSync;
18
+ const tombstone = {
19
+ pid,
20
+ port,
21
+ diedAt: new Date().toISOString(),
22
+ };
23
+ writeFn(tombstonePath(), JSON.stringify(tombstone, null, 2));
24
+ }
25
+ catch {
26
+ }
27
+ }
28
+ function readTombstone(deps = {}) {
29
+ try {
30
+ const readFn = deps.readSync ?? fs_1.readFileSync;
31
+ const content = readFn(tombstonePath(), 'utf-8');
32
+ const parsed = JSON.parse(content);
33
+ if (typeof parsed.pid === 'number' &&
34
+ typeof parsed.port === 'number' &&
35
+ typeof parsed.diedAt === 'string') {
36
+ return parsed;
37
+ }
38
+ return null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function clearTombstone(deps = {}) {
45
+ try {
46
+ const unlinkFn = deps.unlinkSync ?? fs_1.unlinkSync;
47
+ unlinkFn(tombstonePath());
48
+ }
49
+ catch {
50
+ }
51
+ }
@@ -23,12 +23,18 @@ function wireShutdownHooks(opts) {
23
23
  shutdownStarted = true;
24
24
  void (async () => {
25
25
  try {
26
- console.error(`[Shutdown] Requested by ${event.signal}. Stopping services...`);
26
+ try {
27
+ process.stderr.write(`[Shutdown] Requested by ${event.signal}. Stopping services...\n`);
28
+ }
29
+ catch { }
27
30
  await opts.onBeforeTerminate();
28
31
  terminator.terminate({ kind: 'success' });
29
32
  }
30
33
  catch (err) {
31
- console.error('[Shutdown] Error while stopping services:', err);
34
+ try {
35
+ process.stderr.write(`[Shutdown] Error while stopping services: ${err?.stack ?? String(err)}\n`);
36
+ }
37
+ catch { }
32
38
  terminator.terminate({ kind: 'failure' });
33
39
  }
34
40
  })();
@@ -40,10 +46,16 @@ function wireStdoutShutdown(opts) {
40
46
  stdout.on('error', (err) => {
41
47
  const code = err.code;
42
48
  if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') {
43
- console.error('[MCP] stdout pipe broken (client disconnected), initiating shutdown');
49
+ try {
50
+ process.stderr.write('[MCP] stdout pipe broken (client disconnected), initiating shutdown\n');
51
+ }
52
+ catch { }
44
53
  }
45
54
  else {
46
- console.error('[MCP] stdout error:', err);
55
+ try {
56
+ process.stderr.write(`[MCP] stdout error: ${err.message}\n`);
57
+ }
58
+ catch { }
47
59
  }
48
60
  shutdownEvents.emit({ kind: 'shutdown_requested', signal: 'SIGHUP' });
49
61
  });
@@ -52,7 +64,10 @@ function wireStdinShutdown(opts) {
52
64
  const shutdownEvents = container_js_1.container.resolve(tokens_js_1.DI.Runtime.ShutdownEvents);
53
65
  const stdin = opts?.stdin ?? process.stdin;
54
66
  stdin.once('end', () => {
55
- console.error('[MCP] stdin closed, initiating shutdown');
67
+ try {
68
+ process.stderr.write('[MCP] stdin closed, initiating shutdown\n');
69
+ }
70
+ catch { }
56
71
  shutdownEvents.emit({ kind: 'shutdown_requested', signal: 'SIGHUP' });
57
72
  });
58
73
  }
@@ -37,6 +37,7 @@ exports.startStdioServer = startStdioServer;
37
37
  const server_js_1 = require("../server.js");
38
38
  const shutdown_hooks_js_1 = require("./shutdown-hooks.js");
39
39
  const fatal_exit_js_1 = require("./fatal-exit.js");
40
+ const primary_tombstone_js_1 = require("./primary-tombstone.js");
40
41
  const INITIAL_ROOTS_TIMEOUT_MS = 1000;
41
42
  async function fetchInitialRootsWithTimeout(server) {
42
43
  return Promise.race([
@@ -49,7 +50,9 @@ async function fetchInitialRootsWithTimeout(server) {
49
50
  async function startStdioServer() {
50
51
  (0, fatal_exit_js_1.registerFatalHandlers)('stdio');
51
52
  (0, fatal_exit_js_1.logStartup)('stdio');
53
+ (0, primary_tombstone_js_1.clearTombstone)();
52
54
  const { server, ctx, rootsManager } = await (0, server_js_1.composeServer)();
55
+ (0, fatal_exit_js_1.registerGracefulShutdown)(async () => { await ctx.httpServer?.stop(); });
53
56
  const { StdioServerTransport } = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/stdio.js')));
54
57
  const { RootsListChangedNotificationSchema, } = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/types.js')));
55
58
  server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
@@ -81,6 +84,10 @@ async function startStdioServer() {
81
84
  (0, shutdown_hooks_js_1.wireStdinShutdown)();
82
85
  (0, shutdown_hooks_js_1.wireShutdownHooks)({
83
86
  onBeforeTerminate: async () => {
87
+ const port = ctx.httpServer?.getPort();
88
+ if (port != null) {
89
+ (0, primary_tombstone_js_1.writeTombstone)(port, process.pid);
90
+ }
84
91
  await ctx.httpServer?.stop();
85
92
  },
86
93
  });
@@ -37,16 +37,16 @@ function waitForStdinReadable(timeoutMs, stdin = process.stdin) {
37
37
  async function main() {
38
38
  const mode = (0, transport_mode_js_1.resolveTransportMode)(process.env);
39
39
  if (mode.kind === 'stdio') {
40
- const primaryPort = await (0, bridge_entry_js_1.detectHealthyPrimary)(DEFAULT_MCP_PORT);
41
- if (primaryPort != null) {
40
+ const primaryDetected = await (0, bridge_entry_js_1.detectHealthyPrimary)(DEFAULT_MCP_PORT);
41
+ if (primaryDetected != null) {
42
42
  const hasClient = await waitForStdinReadable(STDIO_CLIENT_PROBE_MS, process.stdin);
43
43
  if (!hasClient) {
44
- console.error(`[Startup] Primary on :${primaryPort}, no stdio client within ${STDIO_CLIENT_PROBE_MS}ms — exiting`);
44
+ console.error(`[Startup] Primary on :${primaryDetected.port}, no stdio client within ${STDIO_CLIENT_PROBE_MS}ms — exiting`);
45
45
  process.exit(0);
46
46
  }
47
- console.error(`[Startup] Primary detected on :${primaryPort} — starting in bridge mode`);
47
+ console.error(`[Startup] Primary detected on :${primaryDetected.port} — starting in bridge mode`);
48
48
  try {
49
- await (0, bridge_entry_js_1.startBridgeServer)(primaryPort);
49
+ await (0, bridge_entry_js_1.startBridgeServer)(primaryDetected.port, undefined, { originalPrimaryPid: primaryDetected.pid });
50
50
  }
51
51
  catch (error) {
52
52
  console.error('[Bridge] Fatal error, falling back to full stdio server:', error);
@@ -0,0 +1,24 @@
1
+ import 'reflect-metadata';
2
+ import type { V2ToolContext } from '../mcp/types.js';
3
+ import type { TriggerRouter } from './trigger-router.js';
4
+ import type { WorkflowService } from '../application/services/workflow-service.js';
5
+ import type { Result } from '../runtime/result.js';
6
+ export interface DaemonConsoleHandle {
7
+ readonly port: number;
8
+ stop(): Promise<void>;
9
+ }
10
+ export type DaemonConsoleError = {
11
+ readonly kind: 'port_conflict';
12
+ readonly port: number;
13
+ } | {
14
+ readonly kind: 'io_error';
15
+ readonly message: string;
16
+ };
17
+ export interface StartDaemonConsoleOptions {
18
+ readonly port?: number;
19
+ readonly triggerRouter?: TriggerRouter;
20
+ readonly serverVersion?: string;
21
+ readonly workflowService?: WorkflowService;
22
+ readonly lockFilePath?: string;
23
+ }
24
+ export declare function startDaemonConsole(ctx: V2ToolContext, options?: StartDaemonConsoleOptions): Promise<Result<DaemonConsoleHandle, DaemonConsoleError>>;