@open-mercato/cli 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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 (184) hide show
  1. package/.turbo/turbo-build.log +7 -1
  2. package/dist/agentic/guides/module-facts.json +5509 -599
  3. package/dist/agentic/guides/module-facts.v2.json +5595 -599
  4. package/dist/agentic/guides/modules/ai_assistant/index.md +1 -1
  5. package/dist/agentic/guides/modules/api_docs/index.md +1 -1
  6. package/dist/agentic/guides/modules/api_keys/di-registrations-rich.md +11 -0
  7. package/dist/agentic/guides/modules/api_keys/di-service-tokens.md +11 -0
  8. package/dist/agentic/guides/modules/api_keys/exact-override-targets.md +1 -0
  9. package/dist/agentic/guides/modules/api_keys/index.md +4 -2
  10. package/dist/agentic/guides/modules/attachments/di-registrations-rich.md +6 -5
  11. package/dist/agentic/guides/modules/attachments/di-service-tokens.md +5 -4
  12. package/dist/agentic/guides/modules/attachments/exact-override-targets.md +6 -5
  13. package/dist/agentic/guides/modules/attachments/index.md +2 -2
  14. package/dist/agentic/guides/modules/audit_logs/index.md +1 -1
  15. package/dist/agentic/guides/modules/auth/di-registrations-rich.md +3 -2
  16. package/dist/agentic/guides/modules/auth/di-service-tokens.md +3 -2
  17. package/dist/agentic/guides/modules/auth/exact-override-targets.md +3 -2
  18. package/dist/agentic/guides/modules/auth/index.md +1 -1
  19. package/dist/agentic/guides/modules/business_rules/index.md +1 -1
  20. package/dist/agentic/guides/modules/catalog/incoming-installed-contributions.md +3 -0
  21. package/dist/agentic/guides/modules/catalog/index.md +2 -2
  22. package/dist/agentic/guides/modules/channel_apns/index.md +1 -1
  23. package/dist/agentic/guides/modules/channel_discord/acl-features.md +13 -0
  24. package/dist/agentic/guides/modules/channel_discord/ai-agents.md +11 -0
  25. package/dist/agentic/guides/modules/channel_discord/backend-pages.md +11 -0
  26. package/dist/agentic/guides/modules/channel_discord/cli-commands.md +13 -0
  27. package/dist/agentic/guides/modules/channel_discord/contribution-resolutions.md +14 -0
  28. package/dist/agentic/guides/modules/channel_discord/di-registrations-rich.md +12 -0
  29. package/dist/agentic/guides/modules/channel_discord/exact-override-targets.md +26 -0
  30. package/dist/agentic/guides/modules/channel_discord/index.md +22 -0
  31. package/dist/agentic/guides/modules/channel_discord/owned-contract-module-metadata.md +11 -0
  32. package/dist/agentic/guides/modules/channel_discord/setup.md +11 -0
  33. package/dist/agentic/guides/modules/channel_discord/umes-contributions.md +14 -0
  34. package/dist/agentic/guides/modules/channel_discord/workers.md +12 -0
  35. package/dist/agentic/guides/modules/channel_expo/index.md +1 -1
  36. package/dist/agentic/guides/modules/channel_fcm/index.md +1 -1
  37. package/dist/agentic/guides/modules/channel_gmail/index.md +1 -1
  38. package/dist/agentic/guides/modules/channel_imap/index.md +1 -1
  39. package/dist/agentic/guides/modules/checkout/index.md +1 -1
  40. package/dist/agentic/guides/modules/communication_channels/di-registrations-rich.md +9 -8
  41. package/dist/agentic/guides/modules/communication_channels/exact-override-targets.md +9 -8
  42. package/dist/agentic/guides/modules/communication_channels/incoming-installed-contributions.md +2 -0
  43. package/dist/agentic/guides/modules/communication_channels/index.md +3 -3
  44. package/dist/agentic/guides/modules/configs/index.md +1 -1
  45. package/dist/agentic/guides/modules/content/index.md +1 -1
  46. package/dist/agentic/guides/modules/currencies/index.md +1 -1
  47. package/dist/agentic/guides/modules/customer_accounts/index.md +1 -1
  48. package/dist/agentic/guides/modules/customers/active-extension-bindings.md +6 -3
  49. package/dist/agentic/guides/modules/customers/contribution-resolutions.md +1 -0
  50. package/dist/agentic/guides/modules/customers/events.md +2 -1
  51. package/dist/agentic/guides/modules/customers/incoming-installed-contributions.md +7 -0
  52. package/dist/agentic/guides/modules/customers/index.md +4 -4
  53. package/dist/agentic/guides/modules/customers/umes-contributions.md +1 -0
  54. package/dist/agentic/guides/modules/customers/umes-hosts.md +1 -0
  55. package/dist/agentic/guides/modules/dashboards/index.md +1 -1
  56. package/dist/agentic/guides/modules/data_sync/exact-override-targets.md +2 -2
  57. package/dist/agentic/guides/modules/data_sync/index.md +1 -1
  58. package/dist/agentic/guides/modules/data_sync/workers.md +2 -2
  59. package/dist/agentic/guides/modules/design_system/index.md +1 -1
  60. package/dist/agentic/guides/modules/devices/index.md +1 -1
  61. package/dist/agentic/guides/modules/dictionaries/index.md +1 -1
  62. package/dist/agentic/guides/modules/directory/di-registrations-rich.md +12 -0
  63. package/dist/agentic/guides/modules/directory/di-service-tokens.md +12 -0
  64. package/dist/agentic/guides/modules/directory/exact-override-targets.md +2 -0
  65. package/dist/agentic/guides/modules/directory/index.md +5 -3
  66. package/dist/agentic/guides/modules/documents/acl-features.md +17 -0
  67. package/dist/agentic/guides/modules/documents/backend-pages.md +13 -0
  68. package/dist/agentic/guides/modules/documents/contribution-resolutions.md +27 -0
  69. package/dist/agentic/guides/modules/documents/di-registrations-rich.md +21 -0
  70. package/dist/agentic/guides/modules/documents/domain-commands.md +37 -0
  71. package/dist/agentic/guides/modules/documents/encryption.md +11 -0
  72. package/dist/agentic/guides/modules/documents/entities.md +21 -0
  73. package/dist/agentic/guides/modules/documents/events.md +25 -0
  74. package/dist/agentic/guides/modules/documents/exact-override-targets.md +39 -0
  75. package/dist/agentic/guides/modules/documents/index.md +25 -0
  76. package/dist/agentic/guides/modules/documents/notifications.md +13 -0
  77. package/dist/agentic/guides/modules/documents/owned-contract-module-metadata.md +11 -0
  78. package/dist/agentic/guides/modules/documents/setup.md +11 -0
  79. package/dist/agentic/guides/modules/documents/umes-contributions.md +27 -0
  80. package/dist/agentic/guides/modules/documents/umes-hosts.md +36 -0
  81. package/dist/agentic/guides/modules/entities/index.md +1 -1
  82. package/dist/agentic/guides/modules/eudr/index.md +1 -1
  83. package/dist/agentic/guides/modules/events/index.md +1 -1
  84. package/dist/agentic/guides/modules/feature_toggles/index.md +1 -1
  85. package/dist/agentic/guides/modules/gateway_stripe/exact-override-targets.md +1 -1
  86. package/dist/agentic/guides/modules/gateway_stripe/index.md +1 -1
  87. package/dist/agentic/guides/modules/gateway_stripe/workers.md +1 -1
  88. package/dist/agentic/guides/modules/generators/index.md +1 -1
  89. package/dist/agentic/guides/modules/inbox_ops/index.md +1 -1
  90. package/dist/agentic/guides/modules/integrations/index.md +1 -1
  91. package/dist/agentic/guides/modules/messages/index.md +1 -1
  92. package/dist/agentic/guides/modules/notifications/index.md +1 -1
  93. package/dist/agentic/guides/modules/onboarding/index.md +1 -1
  94. package/dist/agentic/guides/modules/payment_gateways/index.md +1 -1
  95. package/dist/agentic/guides/modules/perspectives/index.md +1 -1
  96. package/dist/agentic/guides/modules/planner/index.md +1 -1
  97. package/dist/agentic/guides/modules/portal/index.md +1 -1
  98. package/dist/agentic/guides/modules/progress/index.md +1 -1
  99. package/dist/agentic/guides/modules/push_notifications/index.md +1 -1
  100. package/dist/agentic/guides/modules/query_index/index.md +1 -1
  101. package/dist/agentic/guides/modules/record_locks/index.md +1 -1
  102. package/dist/agentic/guides/modules/resources/index.md +1 -1
  103. package/dist/agentic/guides/modules/sales/active-extension-bindings.md +1 -0
  104. package/dist/agentic/guides/modules/sales/contribution-resolutions.md +1 -0
  105. package/dist/agentic/guides/modules/sales/exact-override-targets.md +5 -4
  106. package/dist/agentic/guides/modules/sales/host-extension-points.md +1 -1
  107. package/dist/agentic/guides/modules/sales/incoming-installed-contributions.md +2 -0
  108. package/dist/agentic/guides/modules/sales/index.md +3 -3
  109. package/dist/agentic/guides/modules/sales/setup.md +1 -1
  110. package/dist/agentic/guides/modules/sales/umes-contributions.md +1 -0
  111. package/dist/agentic/guides/modules/sales/umes-diagnostics.md +1 -0
  112. package/dist/agentic/guides/modules/sales/umes-hosts.md +9 -0
  113. package/dist/agentic/guides/modules/scheduler/cli-commands.md +1 -0
  114. package/dist/agentic/guides/modules/scheduler/exact-override-targets.md +3 -1
  115. package/dist/agentic/guides/modules/scheduler/index.md +2 -2
  116. package/dist/agentic/guides/modules/scheduler/workers.md +2 -1
  117. package/dist/agentic/guides/modules/search/index.md +1 -1
  118. package/dist/agentic/guides/modules/security/index.md +1 -1
  119. package/dist/agentic/guides/modules/security/owned-contract-module-metadata.md +1 -1
  120. package/dist/agentic/guides/modules/shipping_carriers/index.md +1 -1
  121. package/dist/agentic/guides/modules/sso/index.md +1 -1
  122. package/dist/agentic/guides/modules/staff/index.md +1 -1
  123. package/dist/agentic/guides/modules/storage_s3/index.md +1 -1
  124. package/dist/agentic/guides/modules/sync_akeneo/index.md +1 -1
  125. package/dist/agentic/guides/modules/sync_excel/index.md +1 -1
  126. package/dist/agentic/guides/modules/system_status_overlays/index.md +1 -1
  127. package/dist/agentic/guides/modules/translations/index.md +1 -1
  128. package/dist/agentic/guides/modules/warranty_claims/index.md +1 -1
  129. package/dist/agentic/guides/modules/webhooks/index.md +1 -1
  130. package/dist/agentic/guides/modules/wms/index.md +1 -1
  131. package/dist/agentic/guides/modules/workflows/index.md +1 -1
  132. package/dist/agentic/guides/reference-module-facts.json +2 -2
  133. package/dist/agentic/guides/reference-modules/example/index.md +1 -1
  134. package/dist/agentic/guides/upstream/manifest.json +1 -1
  135. package/dist/agentic/shared/AGENTS.md.template +3 -3
  136. package/dist/agentic/shared/ai/harness/README.md +2 -2
  137. package/dist/agentic/shared/ai/harness/RELEASE.md +8 -6
  138. package/dist/agentic/shared/ai/harness/cases.json +83 -0
  139. package/dist/agentic/shared/ai/harness/cases.schema.json +4 -4
  140. package/dist/agentic/shared/ai/harness/validators.json +1 -1
  141. package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +37 -11
  142. package/dist/lib/generators/ast/index.js +4 -1
  143. package/dist/lib/generators/ast/index.js.map +2 -2
  144. package/dist/lib/generators/ast/source-file.js +8 -1
  145. package/dist/lib/generators/ast/source-file.js.map +2 -2
  146. package/dist/lib/generators/ast/writers.js +9 -1
  147. package/dist/lib/generators/ast/writers.js.map +2 -2
  148. package/dist/lib/generators/module-registry.js +424 -189
  149. package/dist/lib/generators/module-registry.js.map +2 -2
  150. package/dist/lib/generators/route-manifest-shards.js +4 -1
  151. package/dist/lib/generators/route-manifest-shards.js.map +2 -2
  152. package/dist/lib/spawn.js +3 -2
  153. package/dist/lib/spawn.js.map +2 -2
  154. package/dist/lib/testing/integration.js +162 -24
  155. package/dist/lib/testing/integration.js.map +2 -2
  156. package/dist/mercato.js +14 -0
  157. package/dist/mercato.js.map +2 -2
  158. package/package.json +8 -7
  159. package/src/__tests__/resolve-queue-abandon-hook.test.ts +39 -0
  160. package/src/lib/__tests__/spawn.test.ts +23 -0
  161. package/src/lib/generators/__tests__/__snapshots__/bootstrap-registrations.test.ts.snap +14 -0
  162. package/src/lib/generators/__tests__/__snapshots__/command-loaders.test.ts.snap +23 -0
  163. package/src/lib/generators/__tests__/ast-writers-escaping.test.ts +64 -0
  164. package/src/lib/generators/__tests__/bootstrap-registrations.test.ts +176 -0
  165. package/src/lib/generators/__tests__/command-loaders.test.ts +239 -0
  166. package/src/lib/generators/__tests__/generated-registry-imports.test.ts +128 -0
  167. package/src/lib/generators/__tests__/metadata-extraction.test.ts +44 -0
  168. package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +32 -8
  169. package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +4 -1
  170. package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +1 -1
  171. package/src/lib/generators/__tests__/module-subset.test.ts +96 -72
  172. package/src/lib/generators/__tests__/output-snapshots.test.ts +1 -1
  173. package/src/lib/generators/__tests__/registry-variant-parity.test.ts +258 -0
  174. package/src/lib/generators/__tests__/route-manifest-shards.test.ts +15 -0
  175. package/src/lib/generators/__tests__/structural-contracts.test.ts +7 -3
  176. package/src/lib/generators/ast/index.ts +2 -1
  177. package/src/lib/generators/ast/source-file.ts +13 -0
  178. package/src/lib/generators/ast/writers.ts +26 -1
  179. package/src/lib/generators/module-registry.ts +534 -227
  180. package/src/lib/generators/route-manifest-shards.ts +8 -1
  181. package/src/lib/spawn.ts +5 -4
  182. package/src/lib/testing/__tests__/integration.test.ts +435 -0
  183. package/src/lib/testing/integration.ts +254 -32
  184. package/src/mercato.ts +27 -0
@@ -1,4 +1,5 @@
1
1
  import type { ChildProcess, StdioOptions } from 'node:child_process'
2
+ import { spawnSync } from 'node:child_process'
2
3
  import { createServer } from 'node:net'
3
4
  import { existsSync, readFileSync } from 'node:fs'
4
5
  import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
@@ -762,23 +763,56 @@ function runNpxCommand(args: string[], environment: NodeJS.ProcessEnv): Promise<
762
763
  })
763
764
  }
764
765
 
766
+ export type CapturedOutputProcess = ChildProcess & {
767
+ readCapturedOutput?: () => string
768
+ }
769
+
770
+ export const CAPTURED_OUTPUT_MAX_LENGTH = 64 * 1024
771
+
772
+ export function createBoundedOutputBuffer(): { append: (chunk: Buffer | string) => void; read: () => string } {
773
+ let buffered = ''
774
+ return {
775
+ append: (chunk: Buffer | string) => {
776
+ buffered += chunk.toString()
777
+ if (buffered.length > CAPTURED_OUTPUT_MAX_LENGTH) {
778
+ buffered = `…(truncated)…${buffered.slice(-CAPTURED_OUTPUT_MAX_LENGTH)}`
779
+ }
780
+ },
781
+ read: () => buffered,
782
+ }
783
+ }
784
+
785
+ // Both streams are returned when both carry output: a single stderr deprecation notice must not
786
+ // hide the stdout tail that usually holds the real startup failure. The 20-line tail in
787
+ // `exitError()` does the trimming.
788
+ export function formatCapturedOutput(stderrText: string, stdoutText: string): string {
789
+ if (stderrText && stdoutText) {
790
+ return `--- stderr ---\n${stderrText}\n--- stdout ---\n${stdoutText}`
791
+ }
792
+ return stderrText || stdoutText
793
+ }
794
+
765
795
  function startYarnRawCommand(
766
796
  commandArgs: string[],
767
797
  environment: NodeJS.ProcessEnv,
768
- opts: { silent?: boolean } = {},
798
+ opts: { silent?: boolean; detached?: boolean } = {},
769
799
  cwd: string = projectRootDirectory,
770
- ): ChildProcess {
800
+ ): CapturedOutputProcess {
771
801
  const outputMode: StdioOptions = opts.silent ? ['ignore', 'pipe', 'pipe'] : 'inherit'
772
- const resolvedSpawn = resolveSpawnCommand(resolveYarnBinary(), commandArgs)
773
- const processHandle: ChildProcess = spawn(resolvedSpawn.command, resolvedSpawn.args, {
802
+ const resolvedSpawn = resolveSpawnCommand(resolveYarnBinary(), commandArgs, { detached: opts.detached })
803
+ const processHandle: CapturedOutputProcess = spawn(resolvedSpawn.command, resolvedSpawn.args, {
774
804
  cwd,
775
805
  env: environment,
776
806
  stdio: outputMode,
777
807
  ...resolvedSpawn.spawnOptions,
778
808
  })
779
809
  if (opts.silent) {
780
- processHandle.stdout?.on('data', () => {})
781
- processHandle.stderr?.on('data', () => {})
810
+ const stdoutBuffer = createBoundedOutputBuffer()
811
+ const stderrBuffer = createBoundedOutputBuffer()
812
+ processHandle.stdout?.on('data', (chunk: Buffer | string) => stdoutBuffer.append(chunk))
813
+ processHandle.stderr?.on('data', (chunk: Buffer | string) => stderrBuffer.append(chunk))
814
+ processHandle.readCapturedOutput = () =>
815
+ formatCapturedOutput(stderrBuffer.read().trim(), stdoutBuffer.read().trim())
782
816
  }
783
817
  return processHandle
784
818
  }
@@ -786,9 +820,9 @@ function startYarnRawCommand(
786
820
  function startYarnCommand(
787
821
  args: string[],
788
822
  environment: NodeJS.ProcessEnv,
789
- opts: { silent?: boolean } = {},
823
+ opts: { silent?: boolean; detached?: boolean } = {},
790
824
  cwd: string = projectRootDirectory,
791
- ): ChildProcess {
825
+ ): CapturedOutputProcess {
792
826
  return startYarnRawCommand(['run', ...args], environment, opts, cwd)
793
827
  }
794
828
 
@@ -1768,6 +1802,172 @@ function isProcessRunning(processId: number): boolean {
1768
1802
  }
1769
1803
  }
1770
1804
 
1805
+ export type ProcessTreeKillDependencies = {
1806
+ platform?: NodeJS.Platform
1807
+ killPosixProcessGroup?: (pid: number, signal: NodeJS.Signals) => void
1808
+ killWindowsProcessTree?: (pid: number, options: { forced: boolean }) => void
1809
+ gracePeriodMs?: number
1810
+ }
1811
+
1812
+ function defaultKillPosixProcessGroup(pid: number, signal: NodeJS.Signals): void {
1813
+ process.kill(-pid, signal)
1814
+ }
1815
+
1816
+ // `/f` is added only on escalation so the Windows path mirrors the POSIX SIGTERM → grace → SIGKILL
1817
+ // sequence: the first attempt lets the tree shut down cleanly, the second forces it.
1818
+ function defaultKillWindowsProcessTree(pid: number, options: { forced: boolean }): void {
1819
+ spawnSync('taskkill', ['/pid', String(pid), '/t', ...(options.forced ? ['/f'] : [])])
1820
+ }
1821
+
1822
+ export function killProcessTree(
1823
+ pid: number,
1824
+ signal: NodeJS.Signals,
1825
+ dependencies: ProcessTreeKillDependencies = {},
1826
+ ): void {
1827
+ const platform = dependencies.platform ?? process.platform
1828
+ if (platform === 'win32') {
1829
+ const killWindowsProcessTree = dependencies.killWindowsProcessTree ?? defaultKillWindowsProcessTree
1830
+ killWindowsProcessTree(pid, { forced: signal === 'SIGKILL' })
1831
+ return
1832
+ }
1833
+ const killPosixProcessGroup = dependencies.killPosixProcessGroup ?? defaultKillPosixProcessGroup
1834
+ killPosixProcessGroup(pid, signal)
1835
+ }
1836
+
1837
+ const PROCESS_TREE_KILL_GRACE_PERIOD_MS = 3_000
1838
+
1839
+ function killProcessTreeIfRunning(
1840
+ pid: number,
1841
+ signal: NodeJS.Signals,
1842
+ dependencies: ProcessTreeKillDependencies,
1843
+ ): void {
1844
+ try {
1845
+ killProcessTree(pid, signal, dependencies)
1846
+ } catch (error) {
1847
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
1848
+ throw error
1849
+ }
1850
+ }
1851
+ }
1852
+
1853
+ // `getProcessExitPromise` only observes *future* events, so a process that already exited would
1854
+ // never settle it and the caller would stall for the whole grace period. This waiter detaches both
1855
+ // listeners and clears the timer whichever way it settles, so nothing is left pending and a late
1856
+ // `'error'` cannot surface as an unhandled rejection.
1857
+ function waitForProcessExitWithin(childProcess: ChildProcess, timeoutMs: number): Promise<boolean> {
1858
+ return new Promise((resolve) => {
1859
+ const settle = (exited: boolean) => {
1860
+ clearTimeout(gracePeriodTimer)
1861
+ childProcess.off('exit', onExit)
1862
+ childProcess.off('error', onError)
1863
+ resolve(exited)
1864
+ }
1865
+ const onExit = () => settle(true)
1866
+ const onError = () => settle(false)
1867
+ const gracePeriodTimer = setTimeout(() => settle(false), timeoutMs)
1868
+ childProcess.on('exit', onExit)
1869
+ childProcess.on('error', onError)
1870
+ })
1871
+ }
1872
+
1873
+ // A `ChildProcess` reports `null` on both fields while it is alive, so a non-null value means the
1874
+ // exit has already happened and its `'exit'` event will never fire again.
1875
+ function hasProcessAlreadyExited(childProcess: ChildProcess): boolean {
1876
+ return (childProcess.exitCode ?? null) !== null || (childProcess.signalCode ?? null) !== null
1877
+ }
1878
+
1879
+ export async function terminateProcessTree(
1880
+ childProcess: CapturedOutputProcess,
1881
+ dependencies: ProcessTreeKillDependencies = {},
1882
+ ): Promise<void> {
1883
+ const pid = childProcess.pid
1884
+ if (!pid) return
1885
+ // A reaped leader answers only "waiting for its `'exit'` event can never settle" — without this the
1886
+ // crash path would stall the whole teardown for the grace period waiting for an event that already
1887
+ // fired. It does *not* mean the process group is empty: on the readiness-failure path the `yarn`
1888
+ // wrapper exits non-zero while the `mercato start`/Next/worker descendants it spawned stay in its
1889
+ // group, so the group still has to be signalled or #5333's orphan survives verbatim. POSIX keeps
1890
+ // the group id reserved while any member holds it, so signalling it after the leader is reaped is
1891
+ // both safe and effective. Skip the wait, never the signal — and go straight to SIGKILL, since
1892
+ // there is no leader left to coordinate a graceful shutdown or to report the exit we would await.
1893
+ if (hasProcessAlreadyExited(childProcess)) {
1894
+ killProcessTreeIfRunning(pid, 'SIGKILL', dependencies)
1895
+ return
1896
+ }
1897
+
1898
+ killProcessTreeIfRunning(pid, 'SIGTERM', dependencies)
1899
+
1900
+ const gracePeriodMs = dependencies.gracePeriodMs ?? PROCESS_TREE_KILL_GRACE_PERIOD_MS
1901
+ const exitedBeforeGracePeriod = await waitForProcessExitWithin(childProcess, gracePeriodMs)
1902
+
1903
+ if (!exitedBeforeGracePeriod && isProcessRunning(pid)) {
1904
+ killProcessTreeIfRunning(pid, 'SIGKILL', dependencies)
1905
+ }
1906
+ }
1907
+
1908
+ const EPHEMERAL_SHUTDOWN_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
1909
+
1910
+ export type ShutdownProcessRef = Pick<NodeJS.Process, 'once' | 'off' | 'removeAllListeners' | 'kill' | 'pid'>
1911
+
1912
+ export type EphemeralShutdownHandlers = { dispose: () => void }
1913
+
1914
+ // The application is spawned `detached: true`, which puts its whole tree in a new session. A
1915
+ // terminal Ctrl+C only reaches the foreground process group of the controlling terminal, so the
1916
+ // tree never sees the signal and would outlive the run holding `server-start.lock` — exactly the
1917
+ // orphan this harness exists to prevent. Node also skips `'exit'` listeners when it dies from a
1918
+ // signal, so the crash sweep cannot cover this path either. The runner therefore forwards the
1919
+ // interrupt itself: stop the environment, then re-raise the signal so the process still exits with
1920
+ // the conventional 130/143 instead of a synthetic success.
1921
+ export function registerEphemeralShutdownHandlers(options: {
1922
+ stop: () => Promise<void>
1923
+ killApplicationTree: () => void
1924
+ onSignal?: (signal: NodeJS.Signals) => void
1925
+ processRef?: ShutdownProcessRef
1926
+ }): EphemeralShutdownHandlers {
1927
+ const processRef = options.processRef ?? process
1928
+ const onProcessExit = () => options.killApplicationTree()
1929
+ const signalHandlers = new Map<NodeJS.Signals, () => void>()
1930
+
1931
+ // Registered per `startEphemeralEnvironment()` call, and the environment is restarted on retry,
1932
+ // so the handlers must come back off in `stop()` or a retried run stacks dead closures until it
1933
+ // trips MaxListenersExceededWarning.
1934
+ const dispose = () => {
1935
+ processRef.off('exit', onProcessExit)
1936
+ for (const [signal, handler] of signalHandlers) {
1937
+ processRef.off(signal, handler)
1938
+ }
1939
+ signalHandlers.clear()
1940
+ }
1941
+
1942
+ for (const signal of EPHEMERAL_SHUTDOWN_SIGNALS) {
1943
+ const handler = () => {
1944
+ void (async () => {
1945
+ options.onSignal?.(signal)
1946
+ try {
1947
+ await options.stop()
1948
+ } catch (error) {
1949
+ // A teardown failure must not become an unhandled rejection, and must not swallow the
1950
+ // signal: report it and still re-raise so the runner exits the way the shell expects.
1951
+ console.error(`Failed to stop the ephemeral environment on ${signal}:`, error)
1952
+ } finally {
1953
+ dispose()
1954
+ // Load-bearing, not cleanup: `dispose()` already detached this module's `once` handler, so
1955
+ // the only listeners left belong to somebody else (testcontainers, Playwright, a host CLI).
1956
+ // Any survivor would swallow the re-raised signal and turn the interrupt back into the
1957
+ // synthetic success this whole path exists to avoid, so they come off before the re-raise.
1958
+ processRef.removeAllListeners(signal)
1959
+ processRef.kill(processRef.pid as number, signal)
1960
+ }
1961
+ })()
1962
+ }
1963
+ signalHandlers.set(signal, handler)
1964
+ processRef.once(signal, handler)
1965
+ }
1966
+
1967
+ processRef.once('exit', onProcessExit)
1968
+ return { dispose }
1969
+ }
1970
+
1771
1971
  async function getPathAgeMilliseconds(targetPath: string): Promise<number | null> {
1772
1972
  try {
1773
1973
  const stats = await stat(targetPath)
@@ -2094,7 +2294,7 @@ export async function tryReuseExistingEnvironment(options: EphemeralRuntimeOptio
2094
2294
 
2095
2295
  export async function waitForApplicationReadiness(
2096
2296
  baseUrl: string,
2097
- appProcess: ChildProcess,
2297
+ appProcess: CapturedOutputProcess,
2098
2298
  options: { timeoutMs: number; intervalMs?: number; stabilizationMs?: number },
2099
2299
  ): Promise<void> {
2100
2300
  const startTimestamp = Date.now()
@@ -2115,8 +2315,15 @@ export async function waitForApplicationReadiness(
2115
2315
  return { exited: true as const }
2116
2316
  },
2117
2317
  )
2118
- const exitError = () =>
2119
- new Error(`Application process exited before readiness check (exit ${exitCode ?? 'unknown'})`)
2318
+ const exitError = () => {
2319
+ const capturedOutput = appProcess.readCapturedOutput?.().trim()
2320
+ const capturedOutputTail = capturedOutput
2321
+ ? `\nCaptured output:\n${capturedOutput.split('\n').slice(-20).join('\n')}`
2322
+ : ''
2323
+ return new Error(
2324
+ `Application process exited before readiness check (exit ${exitCode ?? 'unknown'})${capturedOutputTail}`,
2325
+ )
2326
+ }
2120
2327
 
2121
2328
  while (Date.now() - startTimestamp < options.timeoutMs) {
2122
2329
  // Run one probe cycle to completion before starting the next. Overlapping cycles (the previous
@@ -3420,21 +3627,45 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
3420
3627
  })
3421
3628
 
3422
3629
  const runtimeLock = await acquireEphemeralRuntimeLock(options.logPrefix)
3423
- let applicationProcess: ChildProcess | null = null
3630
+ let applicationProcess: CapturedOutputProcess | null = null
3424
3631
  let isStopped = false
3632
+ let shutdownHandlers: EphemeralShutdownHandlers | null = null
3425
3633
  const stop = async (): Promise<void> => {
3426
3634
  if (isStopped) return
3427
3635
  isStopped = true
3428
3636
  try {
3429
3637
  if (applicationProcess && !applicationProcess.killed) {
3430
- applicationProcess.kill('SIGTERM')
3638
+ try {
3639
+ await terminateProcessTree(applicationProcess)
3640
+ } catch (error) {
3641
+ // `killProcessTreeIfRunning` rethrows anything that is not `ESRCH`, and this call sits
3642
+ // ahead of the container and state cleanup. A kill that fails must not strand the
3643
+ // Postgres container and the state file too — report it and finish tearing down.
3644
+ console.error(`[${options.logPrefix}] Failed to terminate the application process tree:`, error)
3645
+ }
3431
3646
  }
3432
3647
  await databaseContainer.stop()
3433
3648
  await clearEphemeralEnvironmentState()
3434
3649
  } finally {
3435
3650
  await runtimeLock.release()
3436
- }
3437
- }
3651
+ shutdownHandlers?.dispose()
3652
+ }
3653
+ }
3654
+ shutdownHandlers = registerEphemeralShutdownHandlers({
3655
+ stop,
3656
+ onSignal: (signal) =>
3657
+ console.log(`[${options.logPrefix}] Received ${signal}, stopping ephemeral environment...`),
3658
+ // Deliberately not guarded on `isStopped`: `startEphemeralEnvironment`'s own catch already ran
3659
+ // `stop()` before rethrowing, so the guard made the sweep dead on exactly the paths that need
3660
+ // it. After a successful `stop()` the extra group kill is a harmless `ESRCH` this swallows.
3661
+ killApplicationTree: () => {
3662
+ const pid = applicationProcess?.pid
3663
+ if (!pid) return
3664
+ try {
3665
+ killProcessTree(pid, 'SIGKILL')
3666
+ } catch {}
3667
+ },
3668
+ })
3438
3669
 
3439
3670
  try {
3440
3671
  const appReadyTimeoutMs = resolveAppReadyTimeoutMs(options.logPrefix)
@@ -3521,6 +3752,7 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
3521
3752
  console.log(`[${options.logPrefix}] Starting application on ${applicationBaseUrl}...`)
3522
3753
  const startedAppProcess = startYarnCommand(['start'], commandEnvironment, {
3523
3754
  silent: !options.verbose,
3755
+ detached: true,
3524
3756
  }, appDirectory)
3525
3757
  applicationProcess = startedAppProcess
3526
3758
 
@@ -3559,15 +3791,11 @@ export async function startEphemeralEnvironment(options: EphemeralRuntimeOptions
3559
3791
  }
3560
3792
  }
3561
3793
 
3562
- async function keepEnvironmentRunningForever(options: { logPrefix: string; stop: () => Promise<void> }): Promise<void> {
3563
- const onSignal = async (signal: string): Promise<void> => {
3564
- console.log(`[${options.logPrefix}] Received ${signal}, stopping ephemeral environment...`)
3565
- await options.stop()
3566
- process.exit(0)
3567
- }
3568
-
3569
- process.once('SIGINT', () => void onSignal('SIGINT'))
3570
- process.once('SIGTERM', () => void onSignal('SIGTERM'))
3794
+ // Interrupt handling belongs to `startEphemeralEnvironment`, which owns the detached tree and now
3795
+ // registers SIGINT/SIGTERM handlers for every run — not just the `--keep` ones. Registering a
3796
+ // second pair here would race them: both fire on the same signal, and this one's `process.exit()`
3797
+ // would cut the environment's teardown short mid-await.
3798
+ async function keepEnvironmentRunningForever(): Promise<void> {
3571
3799
  await new Promise<void>(() => {})
3572
3800
  }
3573
3801
 
@@ -3617,10 +3845,7 @@ export async function runIntegrationTestsInEphemeralEnvironment(rawArgs: string[
3617
3845
 
3618
3846
  if (options.keep) {
3619
3847
  console.log('[integration] --keep enabled: leaving app and database running. Press Ctrl+C to stop.')
3620
- await keepEnvironmentRunningForever({
3621
- logPrefix: 'integration',
3622
- stop: environment.stop,
3623
- })
3848
+ await keepEnvironmentRunningForever()
3624
3849
  }
3625
3850
  } finally {
3626
3851
  if (!options.keep) {
@@ -3648,10 +3873,7 @@ export async function runEphemeralAppForQa(rawArgs: string[]): Promise<void> {
3648
3873
  console.log('[ephemeral] Reused existing environment. Press Ctrl+C to exit without stopping the shared runtime.')
3649
3874
  }
3650
3875
 
3651
- await keepEnvironmentRunningForever({
3652
- logPrefix: 'ephemeral',
3653
- stop: environment.stop,
3654
- })
3876
+ await keepEnvironmentRunningForever()
3655
3877
  }
3656
3878
 
3657
3879
  export async function runInteractiveIntegrationInEphemeralEnvironment(rawArgs: string[]): Promise<void> {
package/src/mercato.ts CHANGED
@@ -78,6 +78,29 @@ function getRegisteredCliWorkers(modules: Module[] = getCliModules()): ModuleWor
78
78
  return allWorkers
79
79
  }
80
80
 
81
+ /**
82
+ * Picks the abandoned-job callback for a queue from its workers.
83
+ *
84
+ * The numeric queue options merge across a queue's workers with `Math.max`; two callbacks cannot
85
+ * merge, so the first declared one wins. Silence there would make a genuine wiring mistake — two
86
+ * handlers on one queue each expecting to report its abandoned jobs — look like it works while one of
87
+ * them never runs.
88
+ */
89
+ export function resolveQueueAbandonHook(
90
+ queueName: string,
91
+ queueWorkers: Array<Pick<ModuleWorker, 'id' | 'onJobAbandoned'>>,
92
+ warn: (message: string) => void = console.warn,
93
+ ): ModuleWorker['onJobAbandoned'] {
94
+ const declaring = queueWorkers.filter((worker) => worker.onJobAbandoned)
95
+ if (declaring.length > 1) {
96
+ warn(
97
+ `[worker] Queue "${queueName}" has ${declaring.length} workers declaring onJobAbandoned `
98
+ + `(${declaring.map((worker) => worker.id).join(', ')}); using the first and ignoring the rest.`,
99
+ )
100
+ }
101
+ return declaring[0]?.onJobAbandoned
102
+ }
103
+
81
104
  function shouldEmbedLocalSchedulerInSharedWorker(env: NodeJS.ProcessEnv = process.env): boolean {
82
105
  return parseBooleanToken(env.OM_DEV_EMBED_SCHEDULER_IN_SHARED_WORKER) === true
83
106
  }
@@ -1678,6 +1701,7 @@ export async function run(argv = process.argv) {
1678
1701
  Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1679
1702
  const maxStalledCount = Math.max(...queueWorkers.map((w) => w.maxStalledCount ?? 1), 1)
1680
1703
  const lockDuration = Math.max(...queueWorkers.map((w) => w.lockDuration ?? 0), 0) || undefined
1704
+ const onJobAbandoned = resolveQueueAbandonHook(queue, queueWorkers)
1681
1705
 
1682
1706
  console.log(`[worker] Starting "${queue}" with ${queueWorkers.length} handler(s), concurrency: ${concurrency}`)
1683
1707
 
@@ -1688,6 +1712,7 @@ export async function run(argv = process.argv) {
1688
1712
  concurrency,
1689
1713
  lockDuration,
1690
1714
  maxStalledCount,
1715
+ onJobAbandoned,
1691
1716
  background: true,
1692
1717
  handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
1693
1718
  })
@@ -1733,6 +1758,7 @@ export async function run(argv = process.argv) {
1733
1758
  const concurrency = budgetPlan.entries[0]?.effective ?? requested
1734
1759
  const maxStalledCount = Math.max(...queueWorkers.map((w) => w.maxStalledCount ?? 1), 1)
1735
1760
  const lockDuration = Math.max(...queueWorkers.map((w) => w.lockDuration ?? 0), 0) || undefined
1761
+ const onJobAbandoned = resolveQueueAbandonHook(queueName!, queueWorkers)
1736
1762
 
1737
1763
  console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`)
1738
1764
 
@@ -1743,6 +1769,7 @@ export async function run(argv = process.argv) {
1743
1769
  concurrency,
1744
1770
  lockDuration,
1745
1771
  maxStalledCount,
1772
+ onJobAbandoned,
1746
1773
  handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
1747
1774
  })
1748
1775
  } else {