@omnitron-dev/omnitron 0.1.2 → 0.2.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.
Files changed (268) hide show
  1. package/dist/cli/omnitron.js +32 -1
  2. package/dist/cli/omnitron.js.map +1 -1
  3. package/dist/cluster/leader-election.d.ts.map +1 -1
  4. package/dist/cluster/leader-election.js +0 -2
  5. package/dist/cluster/leader-election.js.map +1 -1
  6. package/dist/commands/env.d.ts.map +1 -1
  7. package/dist/commands/env.js +10 -2
  8. package/dist/commands/env.js.map +1 -1
  9. package/dist/commands/health.d.ts.map +1 -1
  10. package/dist/commands/health.js +10 -2
  11. package/dist/commands/health.js.map +1 -1
  12. package/dist/commands/inspect-graph.d.ts +20 -0
  13. package/dist/commands/inspect-graph.d.ts.map +1 -0
  14. package/dist/commands/inspect-graph.js +78 -0
  15. package/dist/commands/inspect-graph.js.map +1 -0
  16. package/dist/commands/inspect.d.ts.map +1 -1
  17. package/dist/commands/inspect.js +10 -2
  18. package/dist/commands/inspect.js.map +1 -1
  19. package/dist/commands/list.d.ts.map +1 -1
  20. package/dist/commands/list.js +11 -1
  21. package/dist/commands/list.js.map +1 -1
  22. package/dist/commands/logs.d.ts.map +1 -1
  23. package/dist/commands/logs.js +17 -3
  24. package/dist/commands/logs.js.map +1 -1
  25. package/dist/commands/metrics.d.ts.map +1 -1
  26. package/dist/commands/metrics.js +10 -2
  27. package/dist/commands/metrics.js.map +1 -1
  28. package/dist/commands/monit.d.ts.map +1 -1
  29. package/dist/commands/monit.js +9 -0
  30. package/dist/commands/monit.js.map +1 -1
  31. package/dist/commands/output.d.ts +35 -0
  32. package/dist/commands/output.d.ts.map +1 -0
  33. package/dist/commands/output.js +63 -0
  34. package/dist/commands/output.js.map +1 -0
  35. package/dist/commands/stack.d.ts.map +1 -1
  36. package/dist/commands/stack.js +22 -7
  37. package/dist/commands/stack.js.map +1 -1
  38. package/dist/commands/status.d.ts.map +1 -1
  39. package/dist/commands/status.js +17 -0
  40. package/dist/commands/status.js.map +1 -1
  41. package/dist/commands/tor.d.ts +13 -0
  42. package/dist/commands/tor.d.ts.map +1 -0
  43. package/dist/commands/tor.js +100 -0
  44. package/dist/commands/tor.js.map +1 -0
  45. package/dist/commands/up.js +1 -1
  46. package/dist/commands/up.js.map +1 -1
  47. package/dist/config/loader.d.ts.map +1 -1
  48. package/dist/config/loader.js +29 -5
  49. package/dist/config/loader.js.map +1 -1
  50. package/dist/config/types.d.ts +14 -0
  51. package/dist/config/types.d.ts.map +1 -1
  52. package/dist/daemon/daemon-client.d.ts +14 -0
  53. package/dist/daemon/daemon-client.d.ts.map +1 -1
  54. package/dist/daemon/daemon-client.js +4 -0
  55. package/dist/daemon/daemon-client.js.map +1 -1
  56. package/dist/daemon/daemon-entry.js +17 -23
  57. package/dist/daemon/daemon-entry.js.map +1 -1
  58. package/dist/daemon/daemon.d.ts +2 -0
  59. package/dist/daemon/daemon.d.ts.map +1 -1
  60. package/dist/daemon/daemon.js +118 -7
  61. package/dist/daemon/daemon.js.map +1 -1
  62. package/dist/daemon/daemon.module.d.ts.map +1 -1
  63. package/dist/daemon/daemon.module.js +41 -14
  64. package/dist/daemon/daemon.module.js.map +1 -1
  65. package/dist/daemon/daemon.rpc-service.d.ts +19 -0
  66. package/dist/daemon/daemon.rpc-service.d.ts.map +1 -1
  67. package/dist/daemon/daemon.rpc-service.js +14 -0
  68. package/dist/daemon/daemon.rpc-service.js.map +1 -1
  69. package/dist/daemon/pid-manager.d.ts +55 -4
  70. package/dist/daemon/pid-manager.d.ts.map +1 -1
  71. package/dist/daemon/pid-manager.js +123 -16
  72. package/dist/daemon/pid-manager.js.map +1 -1
  73. package/dist/daemon/state-store.d.ts.map +1 -1
  74. package/dist/daemon/state-store.js +43 -1
  75. package/dist/daemon/state-store.js.map +1 -1
  76. package/dist/infrastructure/container-runtime.d.ts +10 -0
  77. package/dist/infrastructure/container-runtime.d.ts.map +1 -1
  78. package/dist/infrastructure/container-runtime.js +111 -36
  79. package/dist/infrastructure/container-runtime.js.map +1 -1
  80. package/dist/infrastructure/infrastructure.service.d.ts +41 -0
  81. package/dist/infrastructure/infrastructure.service.d.ts.map +1 -1
  82. package/dist/infrastructure/infrastructure.service.js +157 -8
  83. package/dist/infrastructure/infrastructure.service.js.map +1 -1
  84. package/dist/infrastructure/phantom-endpoint-janitor.d.ts +72 -0
  85. package/dist/infrastructure/phantom-endpoint-janitor.d.ts.map +1 -0
  86. package/dist/infrastructure/phantom-endpoint-janitor.js +149 -0
  87. package/dist/infrastructure/phantom-endpoint-janitor.js.map +1 -0
  88. package/dist/infrastructure/presets/index.d.ts.map +1 -1
  89. package/dist/infrastructure/presets/index.js +2 -0
  90. package/dist/infrastructure/presets/index.js.map +1 -1
  91. package/dist/infrastructure/presets/postgres.d.ts.map +1 -1
  92. package/dist/infrastructure/presets/postgres.js +9 -3
  93. package/dist/infrastructure/presets/postgres.js.map +1 -1
  94. package/dist/infrastructure/presets/redis.d.ts.map +1 -1
  95. package/dist/infrastructure/presets/redis.js +10 -4
  96. package/dist/infrastructure/presets/redis.js.map +1 -1
  97. package/dist/infrastructure/presets/registry.d.ts.map +1 -1
  98. package/dist/infrastructure/presets/registry.js +12 -0
  99. package/dist/infrastructure/presets/registry.js.map +1 -1
  100. package/dist/infrastructure/presets/tor.d.ts +74 -0
  101. package/dist/infrastructure/presets/tor.d.ts.map +1 -0
  102. package/dist/infrastructure/presets/tor.js +176 -0
  103. package/dist/infrastructure/presets/tor.js.map +1 -0
  104. package/dist/infrastructure/service-resolver.d.ts +31 -0
  105. package/dist/infrastructure/service-resolver.d.ts.map +1 -1
  106. package/dist/infrastructure/service-resolver.js +60 -9
  107. package/dist/infrastructure/service-resolver.js.map +1 -1
  108. package/dist/infrastructure/stack-infra-manager.d.ts +1 -1
  109. package/dist/infrastructure/stack-infra-manager.js +1 -1
  110. package/dist/infrastructure/types.d.ts +17 -0
  111. package/dist/infrastructure/types.d.ts.map +1 -1
  112. package/dist/observability/index.d.ts +4 -0
  113. package/dist/observability/index.d.ts.map +1 -0
  114. package/dist/observability/index.js +3 -0
  115. package/dist/observability/index.js.map +1 -0
  116. package/dist/observability/metrics-bridge.d.ts +87 -0
  117. package/dist/observability/metrics-bridge.d.ts.map +1 -0
  118. package/dist/observability/metrics-bridge.js +295 -0
  119. package/dist/observability/metrics-bridge.js.map +1 -0
  120. package/dist/observability/metrics-server.d.ts +68 -0
  121. package/dist/observability/metrics-server.d.ts.map +1 -0
  122. package/dist/observability/metrics-server.js +146 -0
  123. package/dist/observability/metrics-server.js.map +1 -0
  124. package/dist/orchestrator/app-handle.d.ts +13 -1
  125. package/dist/orchestrator/app-handle.d.ts.map +1 -1
  126. package/dist/orchestrator/app-handle.js +12 -0
  127. package/dist/orchestrator/app-handle.js.map +1 -1
  128. package/dist/orchestrator/bootstrap-process.d.ts +49 -0
  129. package/dist/orchestrator/bootstrap-process.d.ts.map +1 -1
  130. package/dist/orchestrator/bootstrap-process.js +167 -0
  131. package/dist/orchestrator/bootstrap-process.js.map +1 -1
  132. package/dist/orchestrator/build-service.d.ts +50 -3
  133. package/dist/orchestrator/build-service.d.ts.map +1 -1
  134. package/dist/orchestrator/build-service.js +357 -85
  135. package/dist/orchestrator/build-service.js.map +1 -1
  136. package/dist/orchestrator/file-watcher.d.ts.map +1 -1
  137. package/dist/orchestrator/file-watcher.js +60 -38
  138. package/dist/orchestrator/file-watcher.js.map +1 -1
  139. package/dist/orchestrator/orchestrator.service.d.ts +176 -3
  140. package/dist/orchestrator/orchestrator.service.d.ts.map +1 -1
  141. package/dist/orchestrator/orchestrator.service.js +587 -56
  142. package/dist/orchestrator/orchestrator.service.js.map +1 -1
  143. package/dist/orchestrator/process-janitor.d.ts +153 -0
  144. package/dist/orchestrator/process-janitor.d.ts.map +1 -0
  145. package/dist/orchestrator/process-janitor.js +279 -0
  146. package/dist/orchestrator/process-janitor.js.map +1 -0
  147. package/dist/project/config-resolver.d.ts.map +1 -1
  148. package/dist/project/config-resolver.js +38 -12
  149. package/dist/project/config-resolver.js.map +1 -1
  150. package/dist/project/registry.d.ts +1 -0
  151. package/dist/project/registry.d.ts.map +1 -1
  152. package/dist/project/registry.js +30 -0
  153. package/dist/project/registry.js.map +1 -1
  154. package/dist/services/auth.service.d.ts.map +1 -1
  155. package/dist/services/auth.service.js +9 -2
  156. package/dist/services/auth.service.js.map +1 -1
  157. package/dist/services/netron-telemetry-transport.d.ts.map +1 -1
  158. package/dist/services/netron-telemetry-transport.js +0 -4
  159. package/dist/services/netron-telemetry-transport.js.map +1 -1
  160. package/dist/services/node-manager.service.js +1 -1
  161. package/dist/services/node-manager.service.js.map +1 -1
  162. package/dist/services/pipeline.service.d.ts +15 -0
  163. package/dist/services/pipeline.service.d.ts.map +1 -1
  164. package/dist/services/pipeline.service.js +53 -2
  165. package/dist/services/pipeline.service.js.map +1 -1
  166. package/dist/services/project.rpc-service.d.ts +4 -0
  167. package/dist/services/project.rpc-service.d.ts.map +1 -1
  168. package/dist/services/project.rpc-service.js +9 -0
  169. package/dist/services/project.rpc-service.js.map +1 -1
  170. package/dist/services/project.service.d.ts +9 -0
  171. package/dist/services/project.service.d.ts.map +1 -1
  172. package/dist/services/project.service.js +148 -21
  173. package/dist/services/project.service.js.map +1 -1
  174. package/dist/shared/dto/project.d.ts +1 -1
  175. package/dist/shared/dto/project.d.ts.map +1 -1
  176. package/dist/shared/dto/services.d.ts +24 -0
  177. package/dist/shared/dto/services.d.ts.map +1 -1
  178. package/dist/workers/health-monitor.service.d.ts.map +1 -1
  179. package/dist/workers/health-monitor.service.js +4 -1
  180. package/dist/workers/health-monitor.service.js.map +1 -1
  181. package/dist/workers/types.d.ts +3 -0
  182. package/dist/workers/types.d.ts.map +1 -1
  183. package/package.json +19 -18
  184. package/webapp/dist/assets/Badge-D2ZBP5iK.js +1 -0
  185. package/webapp/dist/assets/Card-vO88g06B.js +1 -0
  186. package/webapp/dist/assets/CardActions-C9h7Q2wW.js +1 -0
  187. package/webapp/dist/assets/CardContent-BA_Xmmhk.js +1 -0
  188. package/webapp/dist/assets/CardHeader-ceIs__6x.js +1 -0
  189. package/webapp/dist/assets/Chip-Ckk0xUhK.js +1 -0
  190. package/webapp/dist/assets/DialogActions-BhHM7hov.js +1 -0
  191. package/webapp/dist/assets/DialogContentText-BT2JQRmK.js +1 -0
  192. package/webapp/dist/assets/FormControlLabel-CcQByVS8.js +1 -0
  193. package/webapp/dist/assets/Grid-14PETRQW.js +1 -0
  194. package/webapp/dist/assets/InputAdornment-B8aHIsQ7.js +1 -0
  195. package/webapp/dist/assets/Skeleton-B74-Ds6O.js +37 -0
  196. package/webapp/dist/assets/Switch-DvWD2wh4.js +1 -0
  197. package/webapp/dist/assets/SwitchBase-CDU4nY8O.js +1 -0
  198. package/webapp/dist/assets/Tab-DcCOYYp5.js +1 -0
  199. package/webapp/dist/assets/TableContainer-CzTh4mu-.js +1 -0
  200. package/webapp/dist/assets/TableRow-BySEreZR.js +2 -0
  201. package/webapp/dist/assets/TextField-BLf-BHz2.js +1 -0
  202. package/webapp/dist/assets/alerts-DnRbT41C.js +1 -0
  203. package/webapp/dist/assets/apps-CdR0VH2d.js +1 -0
  204. package/webapp/dist/assets/chunk-DtcAfS_W.js +1 -0
  205. package/webapp/dist/assets/client-DU8I_lDc.js +2 -0
  206. package/webapp/dist/assets/containers-Dw-rRXV1.js +1 -0
  207. package/webapp/dist/assets/dashboard-CzgB0uZz.js +1 -0
  208. package/webapp/dist/assets/dashboard-builder-DsA0yg33.js +1 -0
  209. package/webapp/dist/assets/deployments-DtQAoIcQ.js +1 -0
  210. package/webapp/dist/assets/detail-BJR-G7JG.js +1 -0
  211. package/webapp/dist/assets/detail-nlCeSltl.js +8 -0
  212. package/webapp/dist/assets/index-ByQDwp2r.js +566 -0
  213. package/webapp/dist/assets/isMuiElement-DwF2naaF.js +1 -0
  214. package/webapp/dist/assets/logs-Kn8o4H7k.js +11 -0
  215. package/webapp/dist/assets/metrics-DN8Zu5wT.js +2 -0
  216. package/webapp/dist/assets/nodes-Bgch7AtN.js +7 -0
  217. package/webapp/dist/assets/pipelines-Cry3qm-S.js +4 -0
  218. package/webapp/dist/assets/popper-BJFSj6rc.js +1 -0
  219. package/webapp/dist/assets/preload-helper-DSXbuxSR.js +1 -0
  220. package/webapp/dist/assets/projects-Dym2GI1U.js +1 -0
  221. package/webapp/dist/assets/react-apexcharts.esm-CSGK5Cxn.js +958 -0
  222. package/webapp/dist/assets/react-apexcharts.esm-DEAWOwx5.js +1003 -0
  223. package/webapp/dist/assets/settings-va3zTcow.js +1 -0
  224. package/webapp/dist/assets/sign-in-ik35umbp.js +1 -0
  225. package/webapp/dist/assets/stacks-CVFOnxbF.js +1 -0
  226. package/webapp/dist/assets/system-info-npIXAjX2.js +1 -0
  227. package/webapp/dist/assets/topology-bDe70vLs.js +12 -0
  228. package/webapp/dist/assets/traces-DIIcT8DF.js +1 -0
  229. package/webapp/dist/assets/use-stack-context-DHLwZv1r.js +1 -0
  230. package/webapp/dist/index.html +5 -6
  231. package/webapp/dist/assets/Chip-BySxTjyN.js +0 -1
  232. package/webapp/dist/assets/DialogContentText-YQWiAVrU.js +0 -1
  233. package/webapp/dist/assets/DialogTitle-CDkgUIwp.js +0 -1
  234. package/webapp/dist/assets/FormControlLabel-Bu3CCsBM.js +0 -1
  235. package/webapp/dist/assets/Grid-BFY8hAzL.js +0 -1
  236. package/webapp/dist/assets/InputAdornment-_Dayk-3y.js +0 -3
  237. package/webapp/dist/assets/Skeleton-BGj6Sw8m.js +0 -37
  238. package/webapp/dist/assets/Switch-CA6682vv.js +0 -1
  239. package/webapp/dist/assets/TableContainer-4epq10KJ.js +0 -1
  240. package/webapp/dist/assets/TextField-C73KHMVN.js +0 -1
  241. package/webapp/dist/assets/alerts-JEQALS7Y.js +0 -1
  242. package/webapp/dist/assets/apps-DMPysEKB.js +0 -1
  243. package/webapp/dist/assets/client-DlGPter5.js +0 -110
  244. package/webapp/dist/assets/containers-CvkC7do8.js +0 -1
  245. package/webapp/dist/assets/dashboard-C0Fre9pC.js +0 -1
  246. package/webapp/dist/assets/dashboard-builder-DAghqdpE.js +0 -1
  247. package/webapp/dist/assets/deployments-Cg1lI1uT.js +0 -1
  248. package/webapp/dist/assets/detail-CyFtEson.js +0 -1
  249. package/webapp/dist/assets/detail-DqoSGKT1.js +0 -8
  250. package/webapp/dist/assets/icons-ynIWYfBP.js +0 -3
  251. package/webapp/dist/assets/index-vKrDmEA_.js +0 -259
  252. package/webapp/dist/assets/isMuiElement-BRL3YuCT.js +0 -1
  253. package/webapp/dist/assets/logs-IovIij1C.js +0 -11
  254. package/webapp/dist/assets/metrics-Dufwtwjq.js +0 -1
  255. package/webapp/dist/assets/nodes-Brvu8W3j.js +0 -7
  256. package/webapp/dist/assets/pipelines-t40X4MuC.js +0 -4
  257. package/webapp/dist/assets/prop-types-CvVtHxp4.js +0 -1
  258. package/webapp/dist/assets/react-apexcharts.esm-CLO6gala.js +0 -954
  259. package/webapp/dist/assets/settings-DTAIST3v.js +0 -1
  260. package/webapp/dist/assets/sign-in-DQEAkJum.js +0 -1
  261. package/webapp/dist/assets/stacks-BjM1f45N.js +0 -1
  262. package/webapp/dist/assets/system-info-BQSbSEK-.js +0 -1
  263. package/webapp/dist/assets/topology-6N8-Az_Z.js +0 -12
  264. package/webapp/dist/assets/traces-DVXyb-4m.js +0 -1
  265. package/webapp/dist/assets/use-stack-context-CbE8fxBA.js +0 -1
  266. /package/webapp/dist/assets/{constants-DqFQJ27U.js → constants-BgnT-Xdy.js} +0 -0
  267. /package/webapp/dist/assets/{formatters-J3Xqn6BQ.js → formatters-Do41SRmE.js} +0 -0
  268. /package/webapp/dist/assets/{fs-uP8eiZAR.js → fs-CgZLtHiQ.js} +0 -0
@@ -23,7 +23,7 @@
23
23
  import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import { fileURLToPath } from 'node:url';
26
- import { PoolStrategy, SupervisionStrategy, RestartDecision, } from '@omnitron-dev/titan-pm';
26
+ import { PoolStrategy, SupervisionStrategy, } from '@omnitron-dev/titan-pm';
27
27
  import { EventEmitter } from '@omnitron-dev/eventemitter';
28
28
  import { AppHandle } from './app-handle.js';
29
29
  import { launchClassic } from './classic-launcher.js';
@@ -32,6 +32,7 @@ import { buildRestartPolicy } from '../supervisor/restart-policy.js';
32
32
  import { ServiceRouter } from './service-router.js';
33
33
  import { loadBootstrapConfig, clearCacheFor } from './bootstrap-loader.js';
34
34
  import { BuildService } from './build-service.js';
35
+ import { ProcessJanitor } from './process-janitor.js';
35
36
  import { CLI_VERSION } from '../config/defaults.js';
36
37
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
38
  // Resolve bootstrap-process path — .ts in dev (tsx), .js when compiled
@@ -55,11 +56,38 @@ function mapStrategy(strategy) {
55
56
  return SupervisionStrategy.ONE_FOR_ONE;
56
57
  }
57
58
  }
59
+ /**
60
+ * Strip the `${project}/${stack}/` prefix from a handle key (or
61
+ * leave a bare name untouched). Used to identify "the same physical
62
+ * app under any registration form" so duplicate registrations from
63
+ * stack mode + bare-name RPC paths can be deduplicated.
64
+ *
65
+ * Examples:
66
+ * effectiveAppName('omni/dev/paysys') === 'paysys'
67
+ * effectiveAppName('paysys') === 'paysys'
68
+ * effectiveAppName('a/b/c/paysys') === 'paysys' (last segment)
69
+ */
70
+ function effectiveAppName(key) {
71
+ const idx = key.lastIndexOf('/');
72
+ return idx >= 0 ? key.slice(idx + 1) : key;
73
+ }
74
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
58
75
  export class OrchestratorService extends EventEmitter {
59
76
  logger;
60
77
  pm;
61
78
  stateStore;
62
79
  handles = new Map();
80
+ /**
81
+ * Per-name in-flight start lock (T#57). When two `startApp()` calls
82
+ * arrive concurrently for the same app, the first one's promise is
83
+ * stored here; the second one awaits and returns the same handle
84
+ * rather than racing through to a parallel `launchBootstrapMode` /
85
+ * `launchClassicMode` that would spawn a second worker pool and
86
+ * leak the original. The map is cleared in a `finally` once the
87
+ * launch settles (success or failure) so the next legitimate call
88
+ * for the same name isn't permanently locked out.
89
+ */
90
+ startingApps = new Map();
63
91
  config = null;
64
92
  cwd;
65
93
  metricsTimer = null;
@@ -70,16 +98,71 @@ export class OrchestratorService extends EventEmitter {
70
98
  buildService = null;
71
99
  /** Cached build results per app name */
72
100
  buildResults = new Map();
101
+ /**
102
+ * Per-app restart coalescer state.
103
+ *
104
+ * `inFlight` — the currently-running restart promise (so a concurrent
105
+ * trigger doesn't kick off a parallel restart and tear down the half-
106
+ * started state from the previous one).
107
+ * `triggerSerial` — monotonically incremented by external trigger
108
+ * entry points (BuildService onRebuild, RPC restartApp, manual
109
+ * `omnitron restart`, deploy.service). Internal coalescer arms
110
+ * that queue themselves DO NOT bump it.
111
+ * `firedSerial` — value of `triggerSerial` that the most recently
112
+ * completed restart consumed. A trailing coalesced restart only
113
+ * fires when `triggerSerial > firedSerial`, i.e. a genuinely new
114
+ * external trigger arrived during the in-flight window. Two
115
+ * coalescer arms colliding can no longer cause a self-perpetuating
116
+ * loop because neither bumps `triggerSerial`.
117
+ * `cooldownUntil` — absolute epoch ms timestamp. Any trigger received
118
+ * before this clears is queued instead of fired immediately.
119
+ * Prevents the storm where esbuild's onEnd fires for a freshly-
120
+ * spawned context milliseconds after the app reports online.
121
+ */
122
+ restartCoalescer = new Map();
123
+ /**
124
+ * Wall-clock window in ms during which restart triggers from
125
+ * BuildService.watchApp are deferred after `app:online`. Sized so a
126
+ * normal bundle build + child spawn + DI bootstrap fits comfortably
127
+ * inside; anything legitimately changing more often than this is a
128
+ * developer typing very fast, which the regular debounce handles.
129
+ */
130
+ static POST_ONLINE_COOLDOWN_MS = 3_000;
131
+ /**
132
+ * Orphan-process reaper. Lazily created on first `startAll` so unit
133
+ * tests that hit `startApp` directly don't get a periodic timer.
134
+ */
135
+ janitor = null;
73
136
  /** Daemon's Netron instance for native service routing */
74
137
  daemonNetron = null;
75
138
  /** Daemon Unix socket path for child-to-daemon Netron connections */
76
139
  daemonSocketPath = null;
140
+ /**
141
+ * Promise that resolves once the daemon's Netron is wired up. Apps that
142
+ * declare topology cannot launch until this is ready — instead of failing
143
+ * with "daemon Netron not available" the launcher awaits this promise
144
+ * (with a generous timeout) so transient startup-order interleavings
145
+ * resolve themselves instead of becoming user-visible errors.
146
+ */
147
+ daemonNetronReady;
148
+ resolveDaemonNetronReady = () => undefined;
149
+ /**
150
+ * Optional metrics sink. Set by the daemon at boot via
151
+ * `setMetricsBridge` so the orchestrator can forward janitor sweeps,
152
+ * esbuild rebuilds, and lifecycle events without coupling itself to
153
+ * the metrics module. Null means metrics are disabled (tests, slim
154
+ * bootstraps).
155
+ */
156
+ metricsBridge = null;
77
157
  constructor(logger, pm, stateStore, cwd) {
78
158
  super();
79
159
  this.logger = logger;
80
160
  this.pm = pm;
81
161
  this.stateStore = stateStore;
82
162
  this.cwd = cwd ?? process.cwd();
163
+ this.daemonNetronReady = new Promise((resolve) => {
164
+ this.resolveDaemonNetronReady = resolve;
165
+ });
83
166
  }
84
167
  /**
85
168
  * Set the daemon Netron instance and socket path for native topology routing.
@@ -88,28 +171,155 @@ export class OrchestratorService extends EventEmitter {
88
171
  setDaemonNetron(netron, socketPath) {
89
172
  this.daemonNetron = netron;
90
173
  this.daemonSocketPath = socketPath;
174
+ this.resolveDaemonNetronReady();
91
175
  this.logger.info({ socketPath }, 'Daemon Netron set for native topology routing');
92
176
  }
177
+ /**
178
+ * Wire the metrics bridge. Called once during daemon bootstrap.
179
+ * After this, janitor sweeps, esbuild rebuilds, and orchestrator
180
+ * lifecycle events are forwarded to Prometheus.
181
+ */
182
+ setMetricsBridge(bridge) {
183
+ this.metricsBridge = bridge;
184
+ bridge.attachOrchestrator(this);
185
+ this.wireOrchestratorTelemetry();
186
+ }
187
+ /**
188
+ * Subscribe to our own EventEmitter to forward state-change events to
189
+ * the metrics bridge. Idempotent so repeated `setMetricsBridge` calls
190
+ * (mostly tests) don't multiply listeners.
191
+ */
192
+ telemetryWired = false;
193
+ wireOrchestratorTelemetry() {
194
+ if (this.telemetryWired)
195
+ return;
196
+ this.telemetryWired = true;
197
+ this.on('app:restart', (app, attempt) => {
198
+ this.metricsBridge?.recordAppRestart(app, attempt, 'crash');
199
+ });
200
+ this.on('app:crash', (app) => {
201
+ this.metricsBridge?.recordAppCrash(app);
202
+ });
203
+ this.on('app:escalated', (app) => {
204
+ this.metricsBridge?.recordAppEscalated(app);
205
+ });
206
+ }
207
+ /**
208
+ * Wait until the daemon Netron is available, with a hard ceiling so we
209
+ * never hang forever if `setDaemonNetron()` was never wired in.
210
+ */
211
+ async ensureDaemonNetronReady(timeoutMs = 30_000) {
212
+ if (this.daemonNetron && this.daemonSocketPath)
213
+ return;
214
+ const timeout = new Promise((_resolve, reject) => {
215
+ const timer = setTimeout(() => reject(new Error(`Daemon Netron not initialized within ${timeoutMs}ms — wiring bug?`)), timeoutMs);
216
+ timer.unref?.();
217
+ });
218
+ await Promise.race([this.daemonNetronReady, timeout]);
219
+ }
93
220
  // ============================================================================
94
221
  // Lifecycle
95
222
  // ============================================================================
96
223
  async startAll(config) {
97
224
  this.config = config;
225
+ // Reap any fork-workers left over from a previous daemon BEFORE
226
+ // launching apps. Without this, a hard-killed daemon's children
227
+ // are still alive (now adopted by init); they hold DB connections
228
+ // and sockets that the new apps will fight for.
229
+ await this.ensureJanitor().coldStartSweep();
230
+ // Dependency-aware skip: if app A fails to start, every app that
231
+ // declares A in `dependsOn` (transitively) is skipped instead of
232
+ // attempted. Without this, a downstream app would launch, fail to
233
+ // reach its missing dependency, crash, and chew through its restart
234
+ // budget — masking the real root cause.
235
+ const failed = new Set();
236
+ const blocked = new Set();
98
237
  for (const batch of resolveStartupOrder(config.apps)) {
99
238
  const enabled = batch.filter((e) => e.enabled !== false);
100
239
  await Promise.all(enabled.map(async (e) => {
240
+ const blockingDep = (e.dependsOn ?? []).find((dep) => failed.has(dep) || blocked.has(dep));
241
+ if (blockingDep) {
242
+ blocked.add(e.name);
243
+ this.logger.warn({ app: e.name, blockedBy: blockingDep }, 'Skipping app startup — depends on a failed/blocked app');
244
+ return;
245
+ }
101
246
  try {
102
247
  await this.startApp(e, config);
103
248
  }
104
249
  catch (err) {
250
+ failed.add(e.name);
105
251
  this.logger.error({ app: e.name, error: err.message }, 'Failed to start app — continuing with remaining apps');
106
252
  }
107
253
  }));
108
254
  }
255
+ if (failed.size > 0 || blocked.size > 0) {
256
+ this.logger.warn({ failed: [...failed], blocked: [...blocked] }, 'Startup completed with failures — some apps did not launch');
257
+ }
258
+ // Periodic sweep handles runtime leaks from partially-failed
259
+ // restart cycles (esbuild-rebuild → spawn-fail → orphan).
260
+ this.ensureJanitor().start();
109
261
  this.startMetricsPolling(config.monitoring.metrics.interval);
110
262
  this.persistState();
111
263
  }
264
+ /**
265
+ * Lazily create the orphan-process janitor with the live "owned
266
+ * pids" snapshot wired to the orchestrator's handle map.
267
+ */
268
+ ensureJanitor() {
269
+ if (this.janitor)
270
+ return this.janitor;
271
+ this.janitor = new ProcessJanitor({
272
+ logger: this.logger,
273
+ getOwnedPids: () => this.collectOwnedPids(),
274
+ onMetrics: (m) => this.metricsBridge?.recordJanitorSweep(m),
275
+ });
276
+ return this.janitor;
277
+ }
278
+ /**
279
+ * Walk every live handle's supervisor children and collect the OS
280
+ * pids the orchestrator currently considers ours. Cheap — called
281
+ * every janitor sweep.
282
+ */
283
+ collectOwnedPids() {
284
+ const owned = new Set();
285
+ for (const handle of this.handles.values()) {
286
+ const supervisor = handle.supervisor;
287
+ if (!supervisor)
288
+ continue;
289
+ for (const childName of supervisor.getChildNames()) {
290
+ const processId = supervisor.getChildProcessId(childName);
291
+ if (!processId)
292
+ continue;
293
+ const workerHandle = this.pm.getWorkerHandle(processId);
294
+ const pid = workerHandle?.pid;
295
+ if (typeof pid === 'number')
296
+ owned.add(pid);
297
+ }
298
+ }
299
+ return owned;
300
+ }
112
301
  async startApp(entry, config) {
302
+ // T#57: coalesce concurrent calls for the same app name. Without
303
+ // this, two near-simultaneous `startApp({ name: 'main' })` calls
304
+ // both see `this.handles.get('main')` as undefined-or-not-online
305
+ // (the first one's handle is in status='starting'), both fall
306
+ // through to `launchBootstrapMode`, and we spawn two parallel
307
+ // worker pools competing for the same port. Promise coalescing
308
+ // — return the in-flight promise to the second caller — gives
309
+ // both callers the same handle and ensures only one launch
310
+ // actually runs.
311
+ const inflight = this.startingApps.get(entry.name);
312
+ if (inflight) {
313
+ this.logger.debug({ app: entry.name }, 'Coalescing concurrent startApp into in-flight launch (T#57)');
314
+ return inflight;
315
+ }
316
+ const promise = this.startAppInternal(entry, config).finally(() => {
317
+ this.startingApps.delete(entry.name);
318
+ });
319
+ this.startingApps.set(entry.name, promise);
320
+ return promise;
321
+ }
322
+ async startAppInternal(entry, config) {
113
323
  // Persist ecosystem config for restartApp — stack mode passes config per-app,
114
324
  // but restartApp calls startApp without it.
115
325
  if (config && !this.config) {
@@ -118,6 +328,41 @@ export class OrchestratorService extends EventEmitter {
118
328
  const cfg = config ?? this.config;
119
329
  if (!cfg)
120
330
  throw new Error('Ecosystem config not loaded');
331
+ // Deduplicate by effective app identity. Project-mode registers
332
+ // apps as `${project}/${stack}/${app}`; non-project paths use the
333
+ // bare `${app}` short name. They refer to the SAME physical app
334
+ // and must never coexist as separate handles — the duplicate
335
+ // would compete for the port and the orphan-reaper's owned-pid
336
+ // accounting would split between them.
337
+ //
338
+ // Policy:
339
+ // - exact key already exists, online → return it (idempotent)
340
+ // - exact key already exists, not online → fall through and re-launch
341
+ // under the same key
342
+ // - DIFFERENT key with the same effective short name → reject:
343
+ // operator should restart by canonical name, not register an
344
+ // alias.
345
+ const effective = effectiveAppName(entry.name);
346
+ for (const [key, h] of this.handles) {
347
+ if (key === entry.name)
348
+ continue;
349
+ if (effectiveAppName(key) !== effective)
350
+ continue;
351
+ // Same effective app under a different key.
352
+ if (h.status === 'online') {
353
+ this.logger.warn({ existing: key, attempted: entry.name, effective }, 'App already running under a canonical name — refusing duplicate registration');
354
+ return h;
355
+ }
356
+ // Existing handle is errored/stopped — replace silently so a
357
+ // failed previous launch doesn't permanently block the canonical
358
+ // form.
359
+ this.logger.info({ stale: key, replacing_with: entry.name, effective }, 'Replacing stale duplicate handle');
360
+ this.handles.delete(key);
361
+ // Drop any metrics that were collected under the stale alias so it
362
+ // doesn't haunt future snapshots as a ghost offline app.
363
+ if (key !== entry.name)
364
+ this.metricsBridge?.evictApp(key);
365
+ }
121
366
  const existing = this.handles.get(entry.name);
122
367
  if (existing && existing.status === 'online') {
123
368
  this.logger.warn({ app: entry.name }, 'App already running');
@@ -145,7 +390,8 @@ export class OrchestratorService extends EventEmitter {
145
390
  }
146
391
  }
147
392
  async stopApp(name, force = false, timeout = 10_000) {
148
- const handle = this.handles.get(name);
393
+ const canonical = this.resolveAppName(name) ?? name;
394
+ const handle = this.handles.get(canonical);
149
395
  if (!handle || handle.status === 'stopped')
150
396
  return;
151
397
  handle.markStopping();
@@ -178,6 +424,11 @@ export class OrchestratorService extends EventEmitter {
178
424
  clearInterval(this.metricsTimer);
179
425
  this.metricsTimer = null;
180
426
  }
427
+ // Stop the janitor first so it doesn't race the controlled
428
+ // shutdown by trying to "reap" children that are mid-stop.
429
+ if (this.janitor) {
430
+ this.janitor.stop();
431
+ }
181
432
  let count = 0;
182
433
  // When force=true (daemon shutdown), use short timeout per app (3s SIGTERM, then SIGKILL)
183
434
  const perAppTimeout = force ? 3_000 : 10_000;
@@ -209,36 +460,171 @@ export class OrchestratorService extends EventEmitter {
209
460
  this.buildResults.clear();
210
461
  return count;
211
462
  }
463
+ /**
464
+ * Restart an app, coalescing concurrent triggers.
465
+ *
466
+ * The dev-mode hot-reload path can deliver many restart triggers per
467
+ * second (file save → esbuild rebuild → onEnd → debounced → restart),
468
+ * and naive parallelism here ends in cross-stomping state mutations
469
+ * (one restart's stopApp tearing down the other's freshly-spawned
470
+ * child). This wrapper serialises restarts per app:
471
+ *
472
+ * - If a restart is already in flight, we set the `pending` bit and
473
+ * await the current restart. When it completes we run exactly one
474
+ * follow-up — extra triggers received in the meantime collapse onto
475
+ * the same bit, so a burst of N triggers produces at most 2 actual
476
+ * restarts (the current one + one trailing).
477
+ * - If we're inside the post-online cooldown window, the trigger is
478
+ * queued (via `pending`) and fires after the cooldown clears.
479
+ *
480
+ * The hot path is `restartAppNow()` which contains the actual work and
481
+ * is what tests / non-coalescing callers can hit directly.
482
+ */
212
483
  async restartApp(name) {
484
+ // External entry — record one new trigger and dispatch.
485
+ return this.restartAppCoalesced(name, /* fromExternal */ true);
486
+ }
487
+ /**
488
+ * Internal coalescer dispatcher.
489
+ *
490
+ * `fromExternal=true` bumps `triggerSerial` (a user / file-watcher /
491
+ * RPC asked us to restart). `fromExternal=false` is used by the
492
+ * recursive arms (cooldown-wait, await-inflight) which are NOT new
493
+ * triggers, just continuations — they must not bump or the trailing
494
+ * coalesced restart would re-fire forever.
495
+ */
496
+ async restartAppCoalesced(name, fromExternal) {
497
+ const canonical = this.resolveAppName(name) ?? name;
498
+ const handle = this.handles.get(canonical);
499
+ if (!handle)
500
+ throw new Error(`Unknown app: ${name}`);
501
+ name = canonical;
502
+ const state = this.getCoalescerState(name);
503
+ if (fromExternal)
504
+ state.triggerSerial += 1;
505
+ const observedSerial = state.triggerSerial;
506
+ // Cooldown: the app reported online recently. Wait it out, then
507
+ // proceed as a non-external continuation (we already counted this
508
+ // trigger when we first arrived).
509
+ const now = Date.now();
510
+ if (now < state.cooldownUntil) {
511
+ const waitMs = state.cooldownUntil - now;
512
+ this.logger.debug({ app: name, cooldownRemainingMs: waitMs }, 'Restart trigger received during post-online cooldown — queued');
513
+ await sleep(waitMs);
514
+ return this.restartAppCoalesced(name, /* fromExternal */ false);
515
+ }
516
+ // Already restarting — wait for it, then re-enter as a continuation.
517
+ // The trailing-fire gate at the end of the in-flight finally block
518
+ // is what re-fires us if our serial outranks what got consumed.
519
+ if (state.inFlight) {
520
+ await state.inFlight.catch(() => { });
521
+ // If the in-flight restart already consumed our serial (or a
522
+ // later one), we have nothing new to do — return its result by
523
+ // re-reading the current handle. Otherwise fall through and
524
+ // claim the slot ourselves.
525
+ if (state.firedSerial >= observedSerial) {
526
+ const refreshed = this.handles.get(name);
527
+ if (refreshed)
528
+ return refreshed;
529
+ }
530
+ return this.restartAppCoalesced(name, /* fromExternal */ false);
531
+ }
532
+ // Take the slot. Capture which serial this run is consuming —
533
+ // anything bumped after this is the next run's problem.
534
+ const consumedSerial = state.triggerSerial;
535
+ const promise = this.restartAppNow(name);
536
+ state.inFlight = promise;
537
+ try {
538
+ // Cooldown window is started from the `app:online` transition
539
+ // inside startApp(), not here, so the timer is tied to the moment
540
+ // the new child reports ready, not the moment our promise resolves.
541
+ return await promise;
542
+ }
543
+ finally {
544
+ state.inFlight = null;
545
+ state.firedSerial = consumedSerial;
546
+ // Trailing coalesced fire — only if a GENUINELY NEW external
547
+ // trigger arrived between when we claimed the slot and now.
548
+ // Two coalescer arms colliding can no longer cause this branch
549
+ // to fire because they don't bump triggerSerial.
550
+ if (state.triggerSerial > consumedSerial) {
551
+ this.restartAppCoalesced(name, /* fromExternal */ false).catch((err) => {
552
+ this.logger.error({ app: name, error: err.message }, 'Trailing coalesced restart failed');
553
+ });
554
+ }
555
+ }
556
+ }
557
+ /**
558
+ * Internal restart hot path — assumes coalescer has cleared us to proceed.
559
+ *
560
+ * Architecture note (Fix #247): we deliberately do NOT tear down the
561
+ * esbuild watch or invalidate buildResults here. The watch is what
562
+ * triggered us in the first place — its bundle is already fresh, and
563
+ * disposing the context creates a window where the next save can be
564
+ * missed (esbuild's metafile doesn't carry over between contexts).
565
+ *
566
+ * `launchBootstrapMode` sees the live buildResult + watch and skips
567
+ * the cold rebuild, so a restart cycle is just: kill old child →
568
+ * spawn new child with the bundle that was already on disk. The
569
+ * bootstrap-config cache is cleared because changes to `bootstrap.ts`
570
+ * (topology definition) need to take effect on the next start.
571
+ */
572
+ async restartAppNow(name) {
213
573
  const handle = this.handles.get(name);
214
574
  if (!handle)
215
575
  throw new Error(`Unknown app: ${name}`);
216
576
  const entry = handle.entry;
217
- // In dev mode, clear bootstrap config cache so topology changes are picked up
218
577
  if (this.devMode && entry.bootstrap) {
219
- clearCacheFor(path.resolve(this.cwd, entry.bootstrap));
220
- // Stop esbuild watch and clear stale build result — launchBootstrapMode will rebuild + re-watch
221
- if (this.buildService) {
222
- await this.buildService.unwatchApp(name);
223
- }
224
- this.buildResults.delete(name);
578
+ clearCacheFor(path.resolve(entry.cwd ?? this.cwd, entry.bootstrap));
225
579
  }
226
580
  await this.stopApp(name);
227
581
  return this.startApp(entry);
228
582
  }
583
+ getCoalescerState(name) {
584
+ let state = this.restartCoalescer.get(name);
585
+ if (!state) {
586
+ state = { inFlight: null, triggerSerial: 0, firedSerial: 0, cooldownUntil: 0 };
587
+ this.restartCoalescer.set(name, state);
588
+ }
589
+ return state;
590
+ }
229
591
  /**
230
- * Resolve raw app name to its handle key.
231
- * In stack mode, handles are namespaced (e.g., 'omni/dev/main' for app 'main').
232
- * This finds the handle whose key ends with the raw app name.
592
+ * Start the post-online cooldown for an app. Called from the `app:online`
593
+ * transition (both initial startApp and subsequent restartApp paths
594
+ * funnel through this), so a freshly-online app cannot be torn down by
595
+ * a rebuild signal that was already in flight when it finished booting.
596
+ */
597
+ startPostOnlineCooldown(name) {
598
+ const state = this.getCoalescerState(name);
599
+ state.cooldownUntil = Date.now() + OrchestratorService.POST_ONLINE_COOLDOWN_MS;
600
+ }
601
+ /**
602
+ * Resolve a user-supplied name to a canonical handle key.
603
+ *
604
+ * Inputs accepted:
605
+ * - exact canonical key (`omni/dev/paysys`) → returned as-is
606
+ * - bare short name (`paysys`) → returns the canonical key whose
607
+ * last `/`-segment matches, IF AND ONLY IF the match is unique.
608
+ *
609
+ * Returns `undefined` for no match. Throws on ambiguity (multiple
610
+ * stacks expose the same short name) so the caller can surface a
611
+ * clear error to the operator instead of operating on the wrong
612
+ * app.
233
613
  */
234
614
  resolveAppName(rawName) {
235
615
  if (this.handles.has(rawName))
236
616
  return rawName;
617
+ const effective = effectiveAppName(rawName);
618
+ const matches = [];
237
619
  for (const key of this.handles.keys()) {
238
- if (key.endsWith(`/${rawName}`))
239
- return key;
620
+ if (effectiveAppName(key) === effective)
621
+ matches.push(key);
240
622
  }
241
- return undefined;
623
+ if (matches.length === 0)
624
+ return undefined;
625
+ if (matches.length === 1)
626
+ return matches[0];
627
+ throw new Error(`App name '${rawName}' is ambiguous — multiple matches: ${matches.join(', ')}. Use the full canonical name.`);
242
628
  }
243
629
  /**
244
630
  * Reload app with zero-downtime strategy:
@@ -251,9 +637,11 @@ export class OrchestratorService extends EventEmitter {
251
637
  * zero-downtime is not possible (single-process, no supervisor).
252
638
  */
253
639
  async reloadApp(name) {
254
- const handle = this.handles.get(name);
640
+ const canonical = this.resolveAppName(name) ?? name;
641
+ const handle = this.handles.get(canonical);
255
642
  if (!handle)
256
643
  throw new Error(`Unknown app: ${name}`);
644
+ name = canonical;
257
645
  // Classic mode or no supervisor — cannot do zero-downtime, fall back to restart
258
646
  if (handle.mode !== 'bootstrap' || !handle.supervisor) {
259
647
  return this.restartApp(name);
@@ -313,9 +701,11 @@ export class OrchestratorService extends EventEmitter {
313
701
  // Scaling — delegates to PM supervisor
314
702
  // ============================================================================
315
703
  async scaleApp(name, instances) {
316
- const handle = this.handles.get(name);
704
+ const canonical = this.resolveAppName(name) ?? name;
705
+ const handle = this.handles.get(canonical);
317
706
  if (!handle)
318
707
  throw new Error(`Unknown app: ${name}`);
708
+ name = canonical;
319
709
  if (!this.config)
320
710
  throw new Error('Ecosystem config not loaded');
321
711
  if (handle.mode !== 'bootstrap') {
@@ -345,11 +735,18 @@ export class OrchestratorService extends EventEmitter {
345
735
  return handles.map((h) => this.toProcessInfo(h));
346
736
  }
347
737
  getApp(name) {
348
- const h = this.handles.get(name);
738
+ const canonical = this.resolveAppName(name) ?? name;
739
+ const h = this.handles.get(canonical);
349
740
  return h ? this.toProcessInfo(h) : null;
350
741
  }
351
742
  getHandle(name) {
352
- return this.handles.get(name);
743
+ // Match getAppStatus / getLogs name-resolution semantics — accept either
744
+ // a fully namespaced handle key ('project/stack/app') or the bare app
745
+ // name and resolve to the right handle. Without this, CLI commands like
746
+ // `omnitron env main` fail with "Unknown app: main" even though the
747
+ // app exists as `omni/dev/main`.
748
+ const resolved = this.resolveAppName(name);
749
+ return this.handles.get(resolved ?? name);
353
750
  }
354
751
  /**
355
752
  * List all handle names matching a prefix.
@@ -367,6 +764,35 @@ export class OrchestratorService extends EventEmitter {
367
764
  // ============================================================================
368
765
  // Monitoring — async, delegates to PM supervisor for bootstrap mode
369
766
  // ============================================================================
767
+ /**
768
+ * Pull the live DI dependency graph from a running app's first child
769
+ * worker. Used by the daemon RPC backing `omnitron inspect <app>
770
+ * --graph`. Returns null if the app isn't running, has no children,
771
+ * or the worker doesn't expose `getDependencyGraph` (older bootstraps).
772
+ */
773
+ async getDependencyGraph(name) {
774
+ const handle = this.getHandle(name);
775
+ if (!handle || handle.mode !== 'bootstrap' || !handle.supervisor)
776
+ return null;
777
+ const childNames = handle.supervisor.getChildNames();
778
+ for (const childName of childNames) {
779
+ const processId = handle.supervisor.getChildProcessId(childName);
780
+ if (!processId)
781
+ continue;
782
+ const workerHandle = this.pm.getWorkerHandle(processId);
783
+ const proxy = workerHandle?.proxy;
784
+ if (!proxy?.getDependencyGraph)
785
+ continue;
786
+ try {
787
+ const graph = await proxy.getDependencyGraph();
788
+ return graph;
789
+ }
790
+ catch (err) {
791
+ this.logger.warn({ app: name, child: childName, error: err.message }, 'getDependencyGraph: child threw — trying next worker');
792
+ }
793
+ }
794
+ return null;
795
+ }
370
796
  async getMetrics(name) {
371
797
  const result = {};
372
798
  for (const [appName, handle] of this.handles) {
@@ -488,8 +914,13 @@ export class OrchestratorService extends EventEmitter {
488
914
  }
489
915
  getLogs(name, lines = 100) {
490
916
  const result = [];
917
+ // Stack-mode handles are keyed as `<project>/<stack>/<app>` while CLI
918
+ // users typically pass just the bare app name. Resolve via the same
919
+ // matcher used by getAppStatus() so `omnitron logs main` works in
920
+ // both single-app and stack modes.
921
+ const resolved = name ? this.resolveAppName(name) ?? name : undefined;
491
922
  for (const [appName, handle] of this.handles) {
492
- if (name && appName !== name)
923
+ if (resolved && appName !== resolved)
493
924
  continue;
494
925
  result.push({ app: appName, lines: handle.getLogs(lines) });
495
926
  }
@@ -535,7 +966,7 @@ export class OrchestratorService extends EventEmitter {
535
966
  */
536
967
  async launchBootstrapMode(entry, handle, config) {
537
968
  handle.markStarting();
538
- const bootstrapAbsPath = path.resolve(this.cwd, entry.bootstrap);
969
+ const bootstrapAbsPath = path.resolve(entry.cwd ?? this.cwd, entry.bootstrap);
539
970
  // Load bootstrap definition to check for process topology and requirements
540
971
  let topology;
541
972
  let definition;
@@ -588,25 +1019,92 @@ export class OrchestratorService extends EventEmitter {
588
1019
  }
589
1020
  }
590
1021
  // Build all entry points via esbuild (dev mode bundles from source .ts)
1022
+ //
1023
+ // On a *restart* (as opposed to a first start), buildResults already
1024
+ // has a fresh entry — esbuild's watcher just produced the bundle
1025
+ // that triggered us. Reusing it skips a redundant cold rebuild
1026
+ // (which was costing ~500ms per restart and racing against the
1027
+ // watcher's own incremental output). The watch context is also
1028
+ // preserved (we never unwatched), so we don't re-arm it either.
591
1029
  if (definition && this.devMode) {
592
1030
  try {
593
1031
  if (!this.buildService) {
594
- this.buildService = new BuildService(/* isDev */ true);
1032
+ this.buildService = new BuildService(/* isDev */ true, {
1033
+ onEsbuildReset: (info) => this.metricsBridge?.recordResilientHandleReset(info),
1034
+ onBuildResult: ({ app, kind, durationMs, ok }) => this.metricsBridge?.recordEsbuildBuild(app, kind, durationMs, ok),
1035
+ });
595
1036
  }
596
- const buildResult = await this.buildService.buildApp(entry.name, bootstrapAbsPath, definition);
597
- this.buildResults.set(entry.name, buildResult);
598
- this.logger.info({ app: entry.name, modules: buildResult.modulePaths.size }, 'Built entry points via esbuild');
599
- // Start esbuild watch for incremental rebuilds on source changes.
600
- // On rebuild → restart the app (stop + re-build + re-launch).
601
- await this.buildService.watchApp(entry.name, bootstrapAbsPath, definition, () => {
602
- this.logger.info({ app: entry.name }, 'esbuild rebuild detected — restarting');
603
- this.restartApp(entry.name).catch((err) => {
604
- this.logger.error({ app: entry.name, error: err.message }, 'Restart after esbuild rebuild failed');
1037
+ const existing = this.buildResults.get(entry.name);
1038
+ const existingWatch = this.buildService.isWatching?.(entry.name);
1039
+ // Bundle on-disk presence check. A restart can race against
1040
+ // the user (or a script) removing `.omnitron-build/` between
1041
+ // builds — without this stat the orchestrator hands the
1042
+ // worker a path that no longer exists and the child dies
1043
+ // with `ERR_MODULE_NOT_FOUND` before the supervisor can
1044
+ // recover. Cheap stat, big DX win.
1045
+ const bundlePresent = existing
1046
+ ? fs.existsSync(existing.bootstrapPath)
1047
+ : false;
1048
+ if (existing && existingWatch && bundlePresent) {
1049
+ // Restart path — keep the already-built bundle and live watch.
1050
+ this.logger.debug({ app: entry.name, modules: existing.modulePaths.size }, 'Reusing existing esbuild bundle + watch (restart path)');
1051
+ }
1052
+ else {
1053
+ if (existing && existingWatch && !bundlePresent) {
1054
+ // Bundle was deleted out from under us. Clear the watch
1055
+ // and the cached BuildResult so the next call rebuilds
1056
+ // from cold and re-arms the watcher cleanly.
1057
+ this.logger.warn({ app: entry.name, bootstrapPath: existing.bootstrapPath }, 'Cached bundle missing on disk — forcing rebuild');
1058
+ try {
1059
+ await this.buildService.unwatchApp?.(entry.name);
1060
+ }
1061
+ catch (err) {
1062
+ this.logger.debug({ err, app: entry.name }, 'unwatchApp failed (non-fatal)');
1063
+ }
1064
+ this.buildResults.delete(entry.name);
1065
+ }
1066
+ // First-start path — build everything from cold and arm the watcher.
1067
+ const buildResult = await this.buildService.buildApp(entry.name, bootstrapAbsPath, definition);
1068
+ this.buildResults.set(entry.name, buildResult);
1069
+ this.logger.info({ app: entry.name, modules: buildResult.modulePaths.size }, 'Built entry points via esbuild');
1070
+ // Start esbuild watch for incremental rebuilds on source changes.
1071
+ // On rebuild → fire a restart trigger; the coalescer takes it
1072
+ // from there.
1073
+ await this.buildService.watchApp(entry.name, bootstrapAbsPath, definition, () => {
1074
+ // Gate: skip if the app is in a non-running state — repeated
1075
+ // restarts of an escalated/errored process just churn forever
1076
+ // without forward progress (Fix #248).
1077
+ // Skip restart when the app is in a terminal non-running
1078
+ // state. Includes 'crashed' (supervisor escalated after
1079
+ // exceeding its restart budget) and 'errored' (start
1080
+ // failed). 'stopped' means operator explicitly stopped it.
1081
+ // In all three cases, restarting just churns a known-broken
1082
+ // process; the operator must fix the underlying issue and
1083
+ // call `omnitron start <app>` to clear the state.
1084
+ const status = this.getAppStatus(entry.name);
1085
+ if (status === 'errored' || status === 'crashed' || status === 'stopped') {
1086
+ this.logger.warn({ app: entry.name, status }, 'esbuild rebuild detected but app is not running — skipping restart');
1087
+ return;
1088
+ }
1089
+ this.logger.info({ app: entry.name }, 'esbuild rebuild detected — restarting');
1090
+ this.metricsBridge?.recordEsbuildBuild(entry.name, 'rebuild', 0, true);
1091
+ this.restartApp(entry.name).catch((err) => {
1092
+ this.logger.error({ app: entry.name, error: err.message }, 'Restart after esbuild rebuild failed');
1093
+ });
605
1094
  });
606
- });
1095
+ }
607
1096
  }
608
1097
  catch (err) {
609
- this.logger.error({ app: entry.name, error: err.message }, 'esbuild failed — child processes will fall back to tsx');
1098
+ // The "fall back to tsx" wording was misleading — the orchestrator
1099
+ // does not inject a tsx loader into spawned children, so without
1100
+ // a build the bootstrap-process will try to `import` the source
1101
+ // .ts file (referenced as `./app.module.js` in the topology) and
1102
+ // fail with a cryptic "Cannot find module" downstream. Surface
1103
+ // the build error directly so the operator sees the actual
1104
+ // problem on `omnitron start/restart`.
1105
+ this.logger.error({ app: entry.name, error: err.message }, 'esbuild build failed — app cannot start without bundled output');
1106
+ throw new Error(`Cannot launch '${entry.name}' in dev mode: esbuild build failed (${err.message}). ` +
1107
+ `Fix the build error and retry, or run with bundled output present in apps/${entry.name}/.omnitron-build/.`);
610
1108
  }
611
1109
  }
612
1110
  const buildResult = this.buildResults.get(entry.name);
@@ -634,10 +1132,11 @@ export class OrchestratorService extends EventEmitter {
634
1132
  */
635
1133
  async launchTopology(entry, handle, config, bootstrapAbsPath, topology, buildResult) {
636
1134
  const policy = buildRestartPolicy(entry, config);
637
- if (!this.daemonNetron || !this.daemonSocketPath) {
638
- throw new Error(`Cannot launch topology for '${entry.name}': daemon Netron not available. ` +
639
- 'Ensure setDaemonNetron() is called before starting apps.');
640
- }
1135
+ // Wait for daemon Netron to be wired up. Cheap no-op if already ready;
1136
+ // otherwise blocks (with timeout) instead of immediately failing — the
1137
+ // race between daemon startup and stack auto-resume is benign and just
1138
+ // needs a single rendezvous.
1139
+ await this.ensureDaemonNetronReady();
641
1140
  const serviceRouter = new ServiceRouter(this.daemonNetron, this.logger);
642
1141
  const daemonSocketUrl = `unix://${this.daemonSocketPath}`;
643
1142
  // Topology startup order: providers (topology.expose) before consumers (topology.access).
@@ -712,6 +1211,12 @@ export class OrchestratorService extends EventEmitter {
712
1211
  // pm.pool() returns a Proxy wrapping ProcessPool — execute() dispatches to pool workers
713
1212
  const pool = await this.pm.pool(BOOTSTRAP_PROCESS_PATH, poolOptions);
714
1213
  poolNames.push(procEntry.name);
1214
+ // Track the pool on the AppHandle so `getInfo()` can report the
1215
+ // actual worker count for pool-managed topology entries. Without
1216
+ // this the orchestrator falls back to `supervisor.getChildProcessId()`
1217
+ // which returns null for pool workers (they aren't supervisor
1218
+ // children) and the entry is mis-reported as `status: 'stopped'`.
1219
+ handle.topologyPools.set(procEntry.name, pool);
715
1220
  // Auto-discover @Service metadata from pool workers and expose via ServiceRouter.
716
1221
  // No manual `exports` needed — @Service decorator IS the source of truth.
717
1222
  // Requires explicit `topology: { expose: true }` — opt-in for security.
@@ -781,11 +1286,12 @@ export class OrchestratorService extends EventEmitter {
781
1286
  window: policy.window ?? config.supervision.window,
782
1287
  backoff: policy.backoff ?? config.supervision.backoff,
783
1288
  children,
784
- onChildCrash: async (_child, _error) => {
785
- // Always restart — supervisor's maxRestarts/window handles escalation.
786
- // Critical flag determines whether exceeding max restarts kills the app.
787
- return RestartDecision.RESTART;
788
- },
1289
+ // No custom onChildCrash — supervisor's built-in budget gate
1290
+ // (process-supervisor.ts:524-531) does what we want:
1291
+ // * within maxRestarts/window → RESTART
1292
+ // * over budget → ESCALATE (if child is critical) or IGNORE
1293
+ // Wiring an unconditional RESTART here masked the budget and
1294
+ // turned every crashing child into an infinite respawn storm.
789
1295
  };
790
1296
  // Create supervisor WITHOUT starting — wire events first to avoid race condition
791
1297
  const supervisor = this.pm.createSupervisor(supervisorConfig);
@@ -798,6 +1304,7 @@ export class OrchestratorService extends EventEmitter {
798
1304
  handle.markOnline(childPid ?? process.pid);
799
1305
  }
800
1306
  this.persistState();
1307
+ this.startPostOnlineCooldown(entry.name);
801
1308
  this.emit('app:online', entry.name);
802
1309
  });
803
1310
  // Set supervisor on handle BEFORE start so attachLogCapture can access it
@@ -876,11 +1383,12 @@ export class OrchestratorService extends EventEmitter {
876
1383
  window: policy.window ?? config.supervision.window,
877
1384
  backoff: policy.backoff ?? config.supervision.backoff,
878
1385
  children: [childConfig],
879
- onChildCrash: async (_child, _error) => {
880
- // Always restart — supervisor's maxRestarts/window handles escalation.
881
- // Critical flag determines whether exceeding max restarts kills the app.
882
- return RestartDecision.RESTART;
883
- },
1386
+ // No custom onChildCrash — supervisor's built-in budget gate
1387
+ // (process-supervisor.ts:524-531) does what we want:
1388
+ // * within maxRestarts/window → RESTART
1389
+ // * over budget → ESCALATE (if child is critical) or IGNORE
1390
+ // Wiring an unconditional RESTART here masked the budget and
1391
+ // turned every crashing child into an infinite respawn storm.
884
1392
  };
885
1393
  // Create supervisor WITHOUT starting — wire events first to avoid race condition
886
1394
  const supervisor = this.pm.createSupervisor(supervisorConfig);
@@ -895,6 +1403,7 @@ export class OrchestratorService extends EventEmitter {
895
1403
  handle.markOnline(childPid ?? process.pid);
896
1404
  }
897
1405
  this.persistState();
1406
+ this.startPostOnlineCooldown(entry.name);
898
1407
  this.emit('app:online', entry.name);
899
1408
  });
900
1409
  // Set supervisor on handle BEFORE start so attachLogCapture can access it
@@ -1095,19 +1604,41 @@ export class OrchestratorService extends EventEmitter {
1095
1604
  if (handle.topologyProcesses && handle.topologyProcesses.length > 0 && handle.supervisor) {
1096
1605
  info.processes = [];
1097
1606
  for (const topo of handle.topologyProcesses) {
1098
- const childNames = handle.supervisor.getChildNames();
1099
- // Match topology entry to supervisor child by name
1100
- const childName = childNames.find((cn) => cn.includes(topo.name)) ?? topo.name;
1101
- const processId = handle.supervisor.getChildProcessId(childName);
1607
+ const isPoolEntry = (topo.instances ?? 1) > 1;
1102
1608
  let childPid = null;
1103
1609
  let childStatus = 'stopped';
1104
- if (processId) {
1105
- const wh = this.pm.getWorkerHandle(processId);
1106
- childPid = wh?.pid ?? null;
1107
- childStatus = childPid ? 'online' : 'stopped';
1610
+ if (isPoolEntry) {
1611
+ // Pool-managed topology — the supervisor doesn't know about
1612
+ // these (workers are owned by ProcessPool). Read directly
1613
+ // from the pool ref we stashed at creation time.
1614
+ const pool = handle.topologyPools.get(topo.name);
1615
+ if (pool && pool.size > 0) {
1616
+ childStatus = 'online';
1617
+ // Surface the first worker's PID. Pool workers are
1618
+ // interchangeable so any one is representative; the CLI
1619
+ // prints "PID" as a quick liveness signal, not a unique
1620
+ // identifier.
1621
+ const workerIds = pool.getWorkerIds();
1622
+ if (workerIds.length > 0) {
1623
+ const firstId = workerIds[0];
1624
+ const wh = this.pm.getWorkerHandle(firstId);
1625
+ childPid = wh?.pid ?? null;
1626
+ }
1627
+ }
1628
+ }
1629
+ else {
1630
+ // Single-instance topology — supervisor manages the child directly.
1631
+ const childNames = handle.supervisor.getChildNames();
1632
+ const childName = childNames.find((cn) => cn.includes(topo.name)) ?? topo.name;
1633
+ const processId = handle.supervisor.getChildProcessId(childName);
1634
+ if (processId) {
1635
+ const wh = this.pm.getWorkerHandle(processId);
1636
+ childPid = wh?.pid ?? null;
1637
+ childStatus = childPid ? 'online' : 'stopped';
1638
+ }
1108
1639
  }
1109
1640
  // Derive process type from declarations for backward-compatible DTO
1110
- const derivedType = topo.transports ? 'server' : (topo.instances ?? 1) > 1 ? 'worker' : 'custom';
1641
+ const derivedType = topo.transports ? 'server' : isPoolEntry ? 'worker' : 'custom';
1111
1642
  info.processes.push({
1112
1643
  name: topo.name,
1113
1644
  type: derivedType,