@bendyline/gezel-inference 1.0.5 → 1.0.7

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 (144) hide show
  1. package/dist/cache/controller.d.ts +2 -0
  2. package/dist/cache/controller.js +38 -5
  3. package/dist/catalog/chat-models/index.json +1 -1
  4. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/manifest.json +114 -0
  5. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/versions/1.0.0/manifest.json +15 -0
  6. package/dist/catalog/chat-models/raw-index.json +1 -1
  7. package/dist/catalog/image-loras/index.json +1 -0
  8. package/dist/catalog/image-loras/kr/krea-2-ink-wash/manifest.json +31 -0
  9. package/dist/catalog/image-loras/kr/krea-2-ink-wash/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/manifest.json +32 -0
  11. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/versions/1.0.0/manifest.json +15 -0
  12. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/manifest.json +31 -0
  13. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/versions/1.0.0/manifest.json +15 -0
  14. package/dist/catalog/image-loras/kr/krea-2-pixel-art/manifest.json +31 -0
  15. package/dist/catalog/image-loras/kr/krea-2-pixel-art/versions/1.0.0/manifest.json +15 -0
  16. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/manifest.json +31 -0
  17. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/versions/1.0.0/manifest.json +15 -0
  18. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/manifest.json +31 -0
  19. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/versions/1.0.0/manifest.json +15 -0
  20. package/dist/catalog/image-loras/mo/moss-image-gouache/manifest.json +29 -0
  21. package/dist/catalog/image-loras/mo/moss-image-gouache/versions/1.0.0/manifest.json +15 -0
  22. package/dist/catalog/image-loras/mo/moss-image-sft-4step/manifest.json +30 -0
  23. package/dist/catalog/image-loras/mo/moss-image-sft-4step/versions/1.0.0/manifest.json +15 -0
  24. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/manifest.json +31 -0
  25. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/versions/1.0.0/manifest.json +15 -0
  26. package/dist/catalog/image-loras/raw-index.json +1 -0
  27. package/dist/catalog/image-loras/sd/sdxl-3d-render/manifest.json +29 -0
  28. package/dist/catalog/image-loras/sd/sdxl-3d-render/versions/1.0.0/manifest.json +15 -0
  29. package/dist/catalog/image-loras/sd/sdxl-coloring-book/manifest.json +30 -0
  30. package/dist/catalog/image-loras/sd/sdxl-coloring-book/versions/1.0.0/manifest.json +15 -0
  31. package/dist/catalog/image-loras/sd/sdxl-papercut/manifest.json +29 -0
  32. package/dist/catalog/image-loras/sd/sdxl-papercut/versions/1.0.0/manifest.json +15 -0
  33. package/dist/catalog/image-loras/sd/sdxl-pixel-art/manifest.json +29 -0
  34. package/dist/catalog/image-loras/sd/sdxl-pixel-art/versions/1.0.0/manifest.json +15 -0
  35. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/manifest.json +3 -0
  36. package/dist/catalog/image-models/index.json +1 -1
  37. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/manifest.json +44 -0
  38. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/versions/1.0.0/manifest.json +36 -0
  39. package/dist/catalog/image-models/raw-index.json +1 -1
  40. package/dist/catalog/knowledge-catalogs/index.json +1 -1
  41. package/dist/catalog/knowledge-catalogs/raw-index.json +1 -1
  42. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.10.2/manifest.json +181 -0
  43. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.10.2/manifest.json +181 -0
  44. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.10.2/manifest.json +217 -0
  45. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.10.2/manifest.json +197 -0
  46. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/versions/2026.10.2/manifest.json +197 -0
  47. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.10.2/manifest.json +185 -0
  48. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.10.2/manifest.json +181 -0
  49. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/manifest.json +55 -0
  50. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/versions/2026.10.2/manifest.json +165 -0
  51. package/dist/catalog/knowledge-catalogs/wi/wikipedia-geography/versions/2026.10.2/manifest.json +217 -0
  52. package/dist/catalog/knowledge-catalogs/wi/wikipedia-history/versions/2026.10.2/manifest.json +217 -0
  53. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.10.2/manifest.json +181 -0
  54. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.10.2/manifest.json +185 -0
  55. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.10.2/manifest.json +193 -0
  56. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.10.2/manifest.json +189 -0
  57. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.10.2/manifest.json +185 -0
  58. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.10.2/manifest.json +189 -0
  59. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/versions/2026.10.2/manifest.json +197 -0
  60. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-arts/versions/2026.10.2/manifest.json +209 -0
  61. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-business/versions/2026.10.2/manifest.json +189 -0
  62. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-entertainment/versions/2026.10.2/manifest.json +201 -0
  63. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-military/versions/2026.10.2/manifest.json +185 -0
  64. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-other/versions/2026.10.2/manifest.json +165 -0
  65. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-politics/versions/2026.10.2/manifest.json +197 -0
  66. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-religion/versions/2026.10.2/manifest.json +201 -0
  67. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-science/versions/2026.10.2/manifest.json +205 -0
  68. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-sports/versions/2026.10.2/manifest.json +237 -0
  69. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.10.2/manifest.json +197 -0
  70. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.10.2/manifest.json +189 -0
  71. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.10.2/manifest.json +185 -0
  72. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/versions/2026.10.2/manifest.json +193 -0
  73. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.10.2/manifest.json +185 -0
  74. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.10.2/manifest.json +189 -0
  75. package/dist/catalog/provenance.json +2 -1
  76. package/dist/engine-components.js +2 -2
  77. package/dist/engines/electron-native-reuse.d.ts +7 -1
  78. package/dist/engines/electron-native-reuse.js +12 -4
  79. package/dist/engines/native-file-manifest.json +546 -406
  80. package/dist/engines/native-manifest.d.ts +4 -4
  81. package/dist/engines/native-manifest.js +36 -36
  82. package/dist/engines/resolver.d.ts +1 -1
  83. package/dist/engines/resolver.js +48 -51
  84. package/dist/http/auth-context.d.ts +12 -0
  85. package/dist/http/routes/image-ensure-routes.d.ts +4 -0
  86. package/dist/http/routes/image-ensure-routes.js +102 -0
  87. package/dist/http/routes/v1-image-styles.d.ts +5 -0
  88. package/dist/http/routes/v1-image-styles.js +91 -0
  89. package/dist/http/routes/v1-images.d.ts +1 -0
  90. package/dist/http/routes/v1-images.js +15 -93
  91. package/dist/inference-service.js +2 -1
  92. package/dist/knowledge/worker-host.js +2 -0
  93. package/dist/machine-engine/inspect-runtime.js +12 -3
  94. package/dist/memory/embed-core.js +3 -0
  95. package/dist/memory/embeddings.js +2 -0
  96. package/dist/models/image-fit.js +2 -2
  97. package/dist/models/weight-facts.d.ts +5 -0
  98. package/dist/models/weight-facts.js +5 -2
  99. package/dist/providers/ds4/build-provider.js +1 -1
  100. package/dist/providers/ds4/residency.js +3 -3
  101. package/dist/providers/image/img2img-support.d.ts +36 -1
  102. package/dist/providers/image/img2img-support.js +30 -1
  103. package/dist/providers/image/local-engine.d.ts +1 -0
  104. package/dist/providers/image/local-engine.js +8 -2
  105. package/dist/providers/image/local-factory.d.ts +10 -1
  106. package/dist/providers/image/local-factory.js +33 -6
  107. package/dist/providers/image/mock.d.ts +8 -1
  108. package/dist/providers/image/mock.js +46 -1
  109. package/dist/providers/image/pull-registry.d.ts +25 -3
  110. package/dist/providers/image/pull-registry.js +96 -34
  111. package/dist/providers/image/sd-cpp.d.ts +63 -4
  112. package/dist/providers/image/sd-cpp.js +361 -38
  113. package/dist/providers/image/types.d.ts +76 -4
  114. package/dist/providers/image/types.js +8 -0
  115. package/dist/providers/llama-cpp/build-provider.js +3 -2
  116. package/dist/providers/llama-cpp/cache-adapter.d.ts +3 -0
  117. package/dist/providers/llama-cpp/cache-adapter.js +18 -4
  118. package/dist/providers/llama-cpp/devices.js +2 -2
  119. package/dist/providers/llama-cpp/engine-flags.d.ts +5 -0
  120. package/dist/providers/llama-cpp/engine-flags.js +5 -2
  121. package/dist/providers/llama-cpp/provider.js +20 -4
  122. package/dist/providers/mlx/provider.js +13 -4
  123. package/dist/providers/mlx/python/gezel_mlx_server.py +9 -2
  124. package/dist/providers/mlx/python/spec_decode.py +44 -5
  125. package/dist/providers/native/capacity-broker.d.ts +1 -1
  126. package/dist/providers/native/capacity-broker.js +8 -8
  127. package/dist/providers/native/capacity-budget.js +5 -4
  128. package/dist/providers/native/device-capacity-ledger.d.ts +2 -0
  129. package/dist/providers/native/device-capacity-ledger.js +36 -5
  130. package/dist/providers/native/device-capacity.js +4 -3
  131. package/dist/providers/native/local-engine-runtime.js +4 -4
  132. package/dist/providers/native/process-identity.d.ts +2 -0
  133. package/dist/providers/native/process-identity.js +70 -0
  134. package/dist/relevance/relevance-core.js +2 -0
  135. package/dist/relevance/relevance-model.js +2 -0
  136. package/dist/relevance/settings.js +2 -2
  137. package/dist/runtime-lock.d.ts +43 -5
  138. package/dist/runtime-lock.js +189 -26
  139. package/dist/system/host-memory.d.ts +46 -0
  140. package/dist/system/host-memory.js +89 -0
  141. package/dist/system/memory.js +5 -4
  142. package/dist/transformers-cache.js +2 -0
  143. package/dist/utils/download-common.d.ts +1 -1
  144. package/package.json +8 -8
@@ -2,6 +2,7 @@ import { chmod, mkdir } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { join } from 'node:path';
4
4
  import { CapacityDeniedError } from './capacity-broker.js';
5
+ import { readProcessIdentities } from './process-identity.js';
5
6
  // Keep the newer builtin behind a runtime require. The current tsup/esbuild
6
7
  // target rewrites a static `node:sqlite` import to the nonexistent npm package
7
8
  // `sqlite`, making the daemon bundle fail before it can write runtime state.
@@ -38,9 +39,26 @@ export class DeviceCapacityLedger {
38
39
  await chmod(path, 0o600);
39
40
  db.exec('PRAGMA busy_timeout = 1000');
40
41
  db.exec('CREATE TABLE IF NOT EXISTS claims (id TEXT PRIMARY KEY, claim TEXT NOT NULL)');
42
+ const snapshot = db
43
+ .prepare('SELECT claim FROM claims')
44
+ .all()
45
+ .map((row) => JSON.parse(String(row.claim)));
46
+ const pids = snapshot.flatMap((claim) => [
47
+ claim.ownerPid,
48
+ ...(claim.childPid === undefined ? [] : [claim.childPid]),
49
+ ]);
50
+ if (command.action === 'acquire')
51
+ pids.push(command.ownerPid);
52
+ if (command.action === 'bind')
53
+ pids.push(command.childPid);
54
+ // OS probes may spawn a helper. Never hold SQLite's writer lock while awaiting one.
55
+ const probe = this.options.identities ?? (this.options.alive ? undefined : readProcessIdentities);
56
+ const identities = command.action !== 'release' && probe
57
+ ? await probe(pids).catch(() => new Map())
58
+ : new Map();
41
59
  db.exec('BEGIN IMMEDIATE');
42
60
  try {
43
- const result = this.transact(db, command, sample);
61
+ const result = this.transact(db, command, sample, identities, new Map(snapshot.map((claim) => [claim.id, JSON.stringify(claim)])));
44
62
  db.exec('COMMIT');
45
63
  return result;
46
64
  }
@@ -53,17 +71,27 @@ export class DeviceCapacityLedger {
53
71
  db.close();
54
72
  }
55
73
  }
56
- transact(db, command, sample) {
74
+ transact(db, command, sample, identities, observedClaims) {
57
75
  const alive = this.options.alive ?? processAlive;
76
+ const sameProcess = (pid, identity) => {
77
+ if (!alive(pid))
78
+ return false;
79
+ const current = identities.get(pid);
80
+ return identity === undefined || current === undefined || current === identity;
81
+ };
58
82
  const rows = db.prepare('SELECT claim FROM claims ORDER BY rowid').all();
59
83
  const claims = rows.map((row) => JSON.parse(String(row.claim)));
60
84
  const remove = db.prepare('DELETE FROM claims WHERE id = ?');
61
85
  const save = db.prepare('UPDATE claims SET claim = ? WHERE id = ?');
62
86
  const live = claims.filter((claim) => {
87
+ // Another coordinator can acquire/rebind while the OS probe is in flight.
88
+ // Its newer claim must not be compared with our earlier process snapshot.
89
+ if (observedClaims.get(claim.id) !== JSON.stringify(claim))
90
+ return true;
63
91
  // An unbound grant with a dead owner could have spawned just before the
64
92
  // owner died. Keep it conservatively; never guess that its bytes are free.
65
- const stale = (claim.childPid !== undefined && !alive(claim.childPid)) ||
66
- (!alive(claim.ownerPid) && claim.phase === 'waiting');
93
+ const stale = (claim.childPid !== undefined && !sameProcess(claim.childPid, claim.childIdentity)) ||
94
+ (!sameProcess(claim.ownerPid, claim.ownerIdentity) && claim.phase === 'waiting');
67
95
  if (stale)
68
96
  remove.run(claim.id);
69
97
  return !stale;
@@ -80,6 +108,7 @@ export class DeviceCapacityLedger {
80
108
  own = {
81
109
  id: command.id,
82
110
  ownerPid: command.ownerPid,
111
+ ownerIdentity: identities.get(command.ownerPid),
83
112
  label: command.label,
84
113
  bytes: command.bytes,
85
114
  gpuBytes: command.gpuBytes,
@@ -95,8 +124,10 @@ export class DeviceCapacityLedger {
95
124
  return { state: 'released', releaseRequested: false };
96
125
  if (command.action === 'acquire' && command.priority === 'interactive')
97
126
  own.priority = 'interactive';
98
- if (command.action === 'bind' && own.phase !== 'waiting')
127
+ if (command.action === 'bind' && own.phase !== 'waiting') {
99
128
  own.childPid = command.childPid;
129
+ own.childIdentity = identities.get(command.childPid);
130
+ }
100
131
  if (command.action === 'ready' && own.phase === 'loading')
101
132
  own.phase = 'ready';
102
133
  const active = live.filter((c) => c.phase !== 'waiting');
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
- import { homedir, totalmem } from 'node:os';
3
+ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { DS4_FULL_RESIDENCY_HEADROOM_BYTES, GEZEL_VERSION, NativeCapacityCommandSchema, NativeCapacityReplySchema, awakeNow, createLogger, isUnstampedDevBuild, } from '@bendyline/gezel';
6
6
  import { readSystemServiceRuntime, systemServiceHome } from '@bendyline/gezel-client/node';
7
7
  import { ConfigStore } from '../../fs/config-store.js';
8
8
  import { createPinnedFetch } from '../../remotes/pinned-fetch.js';
9
+ import { totalMemoryBytes } from '../../system/host-memory.js';
9
10
  import { CapacityDeniedError, availableSystemRamBytes, liveRamOsReserveBytes, } from './capacity-broker.js';
10
11
  import { DeviceCapacityLedger } from './device-capacity-ledger.js';
11
12
  import { measuredCapacityBudget } from './measured-budget.js';
@@ -156,7 +157,7 @@ export function staticCapacityCeilings(budget, ramBytes, configuredBytes, platfo
156
157
  }
157
158
  export async function sampleDeviceCapacity(home) {
158
159
  const budget = await measuredCapacityBudget();
159
- const ram = totalmem();
160
+ const ram = totalMemoryBytes();
160
161
  const config = home ? await new ConfigStore(home).readConfig() : {};
161
162
  const ceilings = staticCapacityCeilings(budget, ram, configuredEngineMemoryBytes(config));
162
163
  // The ledger's RAM ceiling and the OS's currently reclaimable RAM answer
@@ -213,7 +214,7 @@ async function capacityExecutor(home) {
213
214
  // broker that is merely unreachable is an outage, never permission to
214
215
  // double-book the machine — so claim the authority before rethrowing.
215
216
  observedMachineAuthorities.add(machineHome);
216
- throw error;
217
+ throw new Error(`Local models can't start: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
217
218
  }
218
219
  const skew = assessBrokerVersionSkew(identity.gezelVersion);
219
220
  if (skew)
@@ -609,10 +609,10 @@ export class LocalEngineRuntime {
609
609
  residentCtxTokens: ds4Block?.residentCtxTokens,
610
610
  });
611
611
  if (line) {
612
- const { totalmem } = await import('node:os');
612
+ const { totalMemoryBytes } = await import('../../system/host-memory.js');
613
613
  bytes = ds4ProjectedResidentBytes(line, resolveDs4LaunchCtx({
614
614
  configured: config.modelContextOverrides?.[`ds4:${modelId}`] ?? config.ds4NumCtx,
615
- ramTieredCtx: totalmem() / 1024 ** 3 >= 192 ? 262_144 : 131_072,
615
+ ramTieredCtx: totalMemoryBytes() / 1024 ** 3 >= 192 ? 262_144 : 131_072,
616
616
  catalogMaxCtx: ds4Block?.maxLaunchCtx,
617
617
  minViableContextTokens: minViableLocalContextTokens(),
618
618
  }));
@@ -1046,8 +1046,8 @@ export class LocalEngineRuntime {
1046
1046
  if (!installed && !hasExplicitSource && !(opts.allowUninstalled && ds4Source)) {
1047
1047
  throw new ModelNotInstalledError(name, modelId);
1048
1048
  }
1049
- const { totalmem } = await import('node:os');
1050
- const ramTieredCtx = totalmem() / 1024 ** 3 >= 192 ? 262_144 : 131_072;
1049
+ const { totalMemoryBytes } = await import('../../system/host-memory.js');
1050
+ const ramTieredCtx = totalMemoryBytes() / 1024 ** 3 >= 192 ? 262_144 : 131_072;
1051
1051
  const ds4Override = config.modelContextOverrides?.[`ds4:${modelId}`];
1052
1052
  const ds4Floor = ds4Override !== undefined ? Math.min(contextFloor, ds4Override) : contextFloor;
1053
1053
  const effective = resolveDs4LaunchCtx({
@@ -0,0 +1,2 @@
1
+ /** Birth stamps survive coordinator restarts and distinguish a reused PID. */
2
+ export declare function readProcessIdentities(pids: readonly number[]): Promise<ReadonlyMap<number, string>>;
@@ -0,0 +1,70 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { promisify } from 'node:util';
4
+ const exec = promisify(execFile);
5
+ /**
6
+ * One PID the probe refuses blanks every row in its batch: macOS ps prints
7
+ * "process id too large" past XNU's PID_MAX, and Get-Process -Id binds Int32.
8
+ * A PID past the limit cannot be live, so it is left out like an absent one.
9
+ */
10
+ const PROBE_PID_LIMIT = {
11
+ darwin: 99_999,
12
+ win32: 2_147_483_647,
13
+ };
14
+ /** Birth stamps survive coordinator restarts and distinguish a reused PID. */
15
+ export async function readProcessIdentities(pids) {
16
+ const limit = PROBE_PID_LIMIT[process.platform] ?? Number.MAX_SAFE_INTEGER;
17
+ const ids = [...new Set(pids)].filter((pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= limit);
18
+ const identities = new Map();
19
+ if (!ids.length)
20
+ return identities;
21
+ try {
22
+ if (process.platform === 'linux') {
23
+ const boot = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim();
24
+ await Promise.all(ids.map(async (pid) => {
25
+ const value = await readFile(`/proc/${pid}/stat`, 'utf8').catch(() => null);
26
+ if (!value)
27
+ return;
28
+ // comm may contain spaces and parentheses; fields after its final ')' start at field 3.
29
+ const start = value
30
+ .slice(value.lastIndexOf(')') + 2)
31
+ .trim()
32
+ .split(/\s+/)[19];
33
+ if (start && /^\d+$/.test(start))
34
+ identities.set(pid, `${boot}:${start}`);
35
+ }));
36
+ }
37
+ else {
38
+ const windows = process.platform === 'win32';
39
+ const { stdout } = await exec(windows ? 'powershell.exe' : 'ps', windows
40
+ ? [
41
+ '-NoProfile',
42
+ '-NonInteractive',
43
+ '-Command',
44
+ `Get-Process -Id ${ids.join(',')} -ErrorAction SilentlyContinue | ForEach-Object { try { [string]$_.Id + ":" + $_.StartTime.ToUniversalTime().Ticks.ToString() } catch {} }`,
45
+ ]
46
+ : ['-p', ids.join(','), '-o', 'pid=', '-o', 'lstart='], {
47
+ windowsHide: true,
48
+ timeout: 3000,
49
+ maxBuffer: 256 * 1024,
50
+ env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' },
51
+ }).catch((error) => {
52
+ // ps (and Windows process lookup) may return live rows alongside a missing PID error.
53
+ if (typeof error.stdout === 'string')
54
+ return { stdout: error.stdout };
55
+ throw error;
56
+ });
57
+ for (const line of stdout.split(/\r?\n/)) {
58
+ const match = windows
59
+ ? /^\s*(\d+):(\d+)\s*$/.exec(line)
60
+ : /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
61
+ if (match && ids.includes(Number(match[1])))
62
+ identities.set(Number(match[1]), match[2]);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // Access denied or an unavailable OS probe is unknown, never proof of death.
68
+ }
69
+ return identities;
70
+ }
@@ -3,6 +3,7 @@ import { createReadStream } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { createLogger } from '@bendyline/gezel';
5
5
  import { transformersGraphOptions } from '@bendyline/gezel-knowledge';
6
+ import { disableOnnxRuntimeTelemetry } from '@bendyline/gezel/native';
6
7
  import { PipelineLoadError } from '../memory/embed-core.js';
7
8
  /**
8
9
  * Cross-encoder inference: load a pinned relevance model from its local
@@ -19,6 +20,7 @@ const BATCH = 8;
19
20
  const MAX_LOADED = 2;
20
21
  const loaded = new Map();
21
22
  async function importTransformers() {
23
+ disableOnnxRuntimeTelemetry();
22
24
  return (await import('@huggingface/transformers'));
23
25
  }
24
26
  export async function loadRelevanceModel(model, transformers) {
@@ -1,5 +1,6 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import { createLogger } from '@bendyline/gezel';
3
+ import { disableOnnxRuntimeTelemetry } from '@bendyline/gezel/native';
3
4
  import { retireModelWorker } from '../utils/retire-model-worker.js';
4
5
  import { findServiceWorkerEntry } from '../utils/service-worker-entry.js';
5
6
  import { disposeRelevanceModels, loadRelevanceModel, scoreRelevancePairs, } from './relevance-core.js';
@@ -185,6 +186,7 @@ class RelevanceModelHost {
185
186
  return null;
186
187
  }
187
188
  try {
189
+ disableOnnxRuntimeTelemetry();
188
190
  const worker = new Worker(entry);
189
191
  worker.on('message', (reply) => {
190
192
  const pending = this.pending.get(reply.id);
@@ -1,5 +1,5 @@
1
- import { availableParallelism } from 'node:os';
2
1
  import { KNOWLEDGE_FILTER_MIN_RELEVANCE, } from '@bendyline/gezel';
2
+ import { availableCpuCount } from '../system/host-memory.js';
3
3
  import { relevanceModelDir } from './install.js';
4
4
  import { DEFAULT_RELEVANCE_MODEL_ID, findRelevanceModel } from './registry.js';
5
5
  const DEFAULT_BUDGETS = {
@@ -76,6 +76,6 @@ export function toResolvedModel(home, spec) {
76
76
  maxTokens: spec.maxTokens,
77
77
  queryMaxTokens: spec.queryMaxTokens,
78
78
  scoreActivation: spec.scoreActivation,
79
- intraOpNumThreads: Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))),
79
+ intraOpNumThreads: Math.max(1, Math.min(4, Math.floor(availableCpuCount() / 2))),
80
80
  };
81
81
  }
@@ -6,24 +6,62 @@
6
6
  export declare class SingleInstanceError extends Error {
7
7
  readonly holderPid: number;
8
8
  readonly lockPath: string;
9
- constructor(holderPid: number, lockPath: string);
9
+ readonly holderHost?: string | undefined;
10
+ constructor(holderPid: number, lockPath: string, holderHost?: string | undefined);
10
11
  }
11
12
  export interface RuntimeLock {
12
13
  release(): Promise<void>;
13
14
  }
15
+ /**
16
+ * Who holds a lock. A pid alone identifies a process only inside one pid
17
+ * namespace on one boot: two containers sharing a volume each see the
18
+ * other's pid as dead (or as an unrelated process), which let a one-shot
19
+ * `docker run … gezel do` reclaim a serving container's live lock. The
20
+ * namespace, boot and host fields say when a pid check is meaningful; a
21
+ * holder outside our namespace is judged by the lock file's heartbeat.
22
+ */
23
+ export interface LockIdentity {
24
+ pid: number;
25
+ hostname?: string;
26
+ bootId?: string;
27
+ pidNs?: string;
28
+ /** Process start time in clock ticks since boot (`/proc/<pid>/stat` field 22). */
29
+ startTicks?: string;
30
+ /** Random per-process id; tells our own heartbeat whether the lock is still ours. */
31
+ instance?: string;
32
+ }
33
+ export declare function procStartTicks(pid: number | 'self'): string | undefined;
34
+ export declare function currentLockIdentity(): LockIdentity;
35
+ export declare function parseLockFile(text: string): LockIdentity | null;
36
+ /**
37
+ * Whether a pid check against `holder` means anything here. Any field the
38
+ * holder recorded must match ours; a legacy pid-only record is assumed to be
39
+ * in our namespace, which is how it was judged before.
40
+ */
41
+ export declare function sharesPidNamespace(holder: LockIdentity, me: LockIdentity): boolean;
14
42
  /**
15
43
  * Acquire an exclusive single-instance lock for a gezel home so two daemons
16
44
  * can't share one `~/.gezel/` and race its on-disk state (config, sessions,
17
45
  * tasks, runtime files). Atomic `O_CREAT|O_EXCL` create of
18
- * `<runtimeDir>/lock` holding the owner pid; a stale lock (dead holder) is
46
+ * `<runtimeDir>/lock` holding the owner's identity; a stale lock is
19
47
  * reclaimed. Throws {@link SingleInstanceError} when a live daemon holds it.
20
48
  *
21
- * The returned `release()` removes the lock — call it on graceful shutdown.
22
- * A hard crash leaves a stale lock that the next start reclaims via the
23
- * pid-liveness check.
49
+ * While held, the lock file's mtime is touched every few seconds: that
50
+ * heartbeat is how a daemon in another container (another pid namespace)
51
+ * is told apart from a dead one.
52
+ *
53
+ * The returned `release()` stops the heartbeat and removes the lock — call
54
+ * it on graceful shutdown. A hard crash leaves a stale lock the next start
55
+ * reclaims.
24
56
  */
25
57
  export declare function acquireSingleInstanceLock(opts: {
26
58
  runtimeDir: string;
27
59
  lockPath: string;
28
60
  isAlive?: (pid: number) => boolean;
61
+ identity?: LockIdentity;
62
+ startTicksOf?: (pid: number) => string | undefined;
63
+ now?: () => number;
64
+ sleep?: (ms: number) => Promise<void>;
65
+ heartbeatIntervalMs?: number;
66
+ staleAfterMs?: number;
29
67
  }): Promise<RuntimeLock>;
@@ -1,4 +1,6 @@
1
- import { mkdir, open, readFile, rm } from 'node:fs/promises';
1
+ import { readFileSync, readlinkSync } from 'node:fs';
2
+ import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
3
+ import { hostname as osHostname } from 'node:os';
2
4
  /**
3
5
  * Thrown when a gezel home already has a live daemon. The CLI's `gezel
4
6
  * start` / the supervisor surface this as an actionable message rather
@@ -7,15 +9,21 @@ import { mkdir, open, readFile, rm } from 'node:fs/promises';
7
9
  export class SingleInstanceError extends Error {
8
10
  holderPid;
9
11
  lockPath;
10
- constructor(holderPid, lockPath) {
11
- super(holderPid > 0
12
- ? `Another gezel daemon is already running for this home (pid ${holderPid}). Stop it first with \`gezel stop\`, or use a different GEZEL_HOME.`
13
- : `Could not acquire the single-instance lock at ${lockPath}.`);
12
+ holderHost;
13
+ constructor(holderPid, lockPath, holderHost) {
14
+ super(holderHost !== undefined
15
+ ? `Another Gezel daemon (host "${holderHost}", pid ${holderPid}) is using this home. If it runs in a container, run commands inside that container with \`docker exec <container> gezel …\` instead of starting a second container on the same volume.`
16
+ : holderPid > 0
17
+ ? `Another gezel daemon is already running for this home (pid ${holderPid}). Stop it first with \`gezel stop\`, or use a different GEZEL_HOME.`
18
+ : `Could not acquire the single-instance lock at ${lockPath}.`);
14
19
  this.holderPid = holderPid;
15
20
  this.lockPath = lockPath;
21
+ this.holderHost = holderHost;
16
22
  this.name = 'SingleInstanceError';
17
23
  }
18
24
  }
25
+ const HEARTBEAT_INTERVAL_MS = 10_000;
26
+ const STALE_AFTER_MS = 40_000;
19
27
  function defaultIsAlive(pid) {
20
28
  try {
21
29
  process.kill(pid, 0);
@@ -27,55 +35,177 @@ function defaultIsAlive(pid) {
27
35
  return err.code === 'EPERM';
28
36
  }
29
37
  }
30
- async function readHolderPid(lockPath) {
38
+ function readTrimmed(path) {
31
39
  try {
32
- const pid = Number.parseInt((await readFile(lockPath, 'utf8')).trim(), 10);
33
- return Number.isFinite(pid) ? pid : null;
40
+ return readFileSync(path, 'utf8').trim() || undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ export function procStartTicks(pid) {
47
+ if (process.platform !== 'linux')
48
+ return undefined;
49
+ const stat = readTrimmed(`/proc/${pid}/stat`);
50
+ if (!stat)
51
+ return undefined;
52
+ // comm (field 2) may contain spaces and parentheses; fields resume after
53
+ // the last ')'. starttime is field 22 overall, the 20th after comm.
54
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
55
+ return rest[19];
56
+ }
57
+ export function currentLockIdentity() {
58
+ const linux = process.platform === 'linux';
59
+ let pidNs;
60
+ if (linux) {
61
+ try {
62
+ pidNs = readlinkSync('/proc/self/ns/pid');
63
+ }
64
+ catch {
65
+ pidNs = undefined;
66
+ }
67
+ }
68
+ return {
69
+ pid: process.pid,
70
+ hostname: osHostname(),
71
+ bootId: linux ? readTrimmed('/proc/sys/kernel/random/boot_id') : undefined,
72
+ pidNs,
73
+ startTicks: procStartTicks('self'),
74
+ instance: `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
75
+ };
76
+ }
77
+ /**
78
+ * The pid stays on the first line so a daemon from before the identity
79
+ * record (which parses the leading integer) still reads it as held instead
80
+ * of stale.
81
+ */
82
+ function serializeIdentity(identity) {
83
+ return `${identity.pid}\n${JSON.stringify({ v: 1, ...identity })}\n`;
84
+ }
85
+ export function parseLockFile(text) {
86
+ const [first, second] = text.split('\n');
87
+ const pid = Number.parseInt((first ?? '').trim(), 10);
88
+ if (!Number.isFinite(pid))
89
+ return null;
90
+ if (!second?.trim())
91
+ return { pid };
92
+ try {
93
+ const record = JSON.parse(second);
94
+ const text = (value) => (typeof value === 'string' && value ? value : undefined);
95
+ return {
96
+ pid,
97
+ hostname: text(record.hostname),
98
+ bootId: text(record.bootId),
99
+ pidNs: text(record.pidNs),
100
+ startTicks: text(record.startTicks),
101
+ instance: text(record.instance),
102
+ };
103
+ }
104
+ catch {
105
+ return { pid };
106
+ }
107
+ }
108
+ async function readHolder(lockPath) {
109
+ try {
110
+ return parseLockFile(await readFile(lockPath, 'utf8'));
34
111
  }
35
112
  catch {
36
113
  return null;
37
114
  }
38
115
  }
116
+ /**
117
+ * Whether a pid check against `holder` means anything here. Any field the
118
+ * holder recorded must match ours; a legacy pid-only record is assumed to be
119
+ * in our namespace, which is how it was judged before.
120
+ */
121
+ export function sharesPidNamespace(holder, me) {
122
+ const same = (a, b) => !a || !b || a === b;
123
+ return (same(holder.hostname, me.hostname) &&
124
+ same(holder.bootId, me.bootId) &&
125
+ same(holder.pidNs, me.pidNs));
126
+ }
127
+ async function lockMtimeMs(lockPath) {
128
+ try {
129
+ return (await stat(lockPath)).mtimeMs;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /**
136
+ * A holder outside our pid namespace is alive while its heartbeat moves.
137
+ * A fresh-looking mtime may be a holder that died moments ago (a killed
138
+ * container restarting onto its own volume), so wait until the heartbeat
139
+ * either advances (alive) or ages past the stale bound (dead).
140
+ */
141
+ async function foreignHolderIsAlive(lockPath, opts) {
142
+ const first = await lockMtimeMs(lockPath);
143
+ if (first === null)
144
+ return false;
145
+ for (;;) {
146
+ if (opts.now() - first > opts.staleAfterMs)
147
+ return false;
148
+ await opts.sleep(1_000);
149
+ const current = await lockMtimeMs(lockPath);
150
+ if (current === null)
151
+ return false;
152
+ if (current !== first)
153
+ return true;
154
+ }
155
+ }
39
156
  /**
40
157
  * Acquire an exclusive single-instance lock for a gezel home so two daemons
41
158
  * can't share one `~/.gezel/` and race its on-disk state (config, sessions,
42
159
  * tasks, runtime files). Atomic `O_CREAT|O_EXCL` create of
43
- * `<runtimeDir>/lock` holding the owner pid; a stale lock (dead holder) is
160
+ * `<runtimeDir>/lock` holding the owner's identity; a stale lock is
44
161
  * reclaimed. Throws {@link SingleInstanceError} when a live daemon holds it.
45
162
  *
46
- * The returned `release()` removes the lock — call it on graceful shutdown.
47
- * A hard crash leaves a stale lock that the next start reclaims via the
48
- * pid-liveness check.
163
+ * While held, the lock file's mtime is touched every few seconds: that
164
+ * heartbeat is how a daemon in another container (another pid namespace)
165
+ * is told apart from a dead one.
166
+ *
167
+ * The returned `release()` stops the heartbeat and removes the lock — call
168
+ * it on graceful shutdown. A hard crash leaves a stale lock the next start
169
+ * reclaims.
49
170
  */
50
171
  export async function acquireSingleInstanceLock(opts) {
51
172
  const isAlive = opts.isAlive ?? defaultIsAlive;
173
+ const me = opts.identity ?? currentLockIdentity();
174
+ const startTicksOf = opts.startTicksOf ?? procStartTicks;
175
+ const now = opts.now ?? Date.now;
176
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
177
+ const staleAfterMs = opts.staleAfterMs ?? STALE_AFTER_MS;
52
178
  await mkdir(opts.runtimeDir, { recursive: true });
53
179
  for (let attempt = 0; attempt < 3; attempt++) {
54
180
  try {
55
181
  const handle = await open(opts.lockPath, 'wx'); // fails with EEXIST if present
56
182
  try {
57
- await handle.writeFile(`${process.pid}\n`);
183
+ await handle.writeFile(serializeIdentity(me));
58
184
  }
59
185
  finally {
60
186
  await handle.close();
61
187
  }
62
- return {
63
- async release() {
64
- // Only remove the lock if it's still ours — defends against a
65
- // narrow race where another process reclaimed our stale lock.
66
- const holder = await readHolderPid(opts.lockPath);
67
- if (holder === null || holder === process.pid) {
68
- await rm(opts.lockPath, { force: true }).catch(() => { });
69
- }
70
- },
71
- };
188
+ return holdLock(opts.lockPath, me, opts.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS);
72
189
  }
73
190
  catch (err) {
74
191
  if (err.code !== 'EEXIST')
75
192
  throw err;
76
- const holder = await readHolderPid(opts.lockPath);
77
- if (holder !== null && holder !== process.pid && isAlive(holder)) {
78
- throw new SingleInstanceError(holder, opts.lockPath);
193
+ const holder = await readHolder(opts.lockPath);
194
+ if (holder) {
195
+ if (!sharesPidNamespace(holder, me)) {
196
+ if (await foreignHolderIsAlive(opts.lockPath, { now, sleep, staleAfterMs })) {
197
+ throw new SingleInstanceError(holder.pid, opts.lockPath, holder.hostname ?? 'unknown');
198
+ }
199
+ }
200
+ else if (holder.pid !== me.pid && isAlive(holder.pid)) {
201
+ // A recorded start time that no longer matches means the pid was
202
+ // reused by an unrelated process after the holder died.
203
+ const reused = holder.startTicks !== undefined &&
204
+ startTicksOf(holder.pid) !== undefined &&
205
+ startTicksOf(holder.pid) !== holder.startTicks;
206
+ if (!reused)
207
+ throw new SingleInstanceError(holder.pid, opts.lockPath);
208
+ }
79
209
  }
80
210
  // Stale (dead holder / our own pid / unreadable) — reclaim and retry.
81
211
  await rm(opts.lockPath, { force: true }).catch(() => { });
@@ -83,3 +213,36 @@ export async function acquireSingleInstanceLock(opts) {
83
213
  }
84
214
  throw new SingleInstanceError(-1, opts.lockPath);
85
215
  }
216
+ function holdLock(lockPath, me, intervalMs) {
217
+ const isOurs = (holder) => holder === null ||
218
+ (me.instance !== undefined && holder.instance !== undefined
219
+ ? holder.instance === me.instance
220
+ : holder.pid === me.pid);
221
+ let timer = setInterval(() => {
222
+ void (async () => {
223
+ const holder = await readHolder(lockPath);
224
+ if (holder === null || !isOurs(holder)) {
225
+ // Someone else owns the file now; touching it would keep their lock
226
+ // looking alive on our behalf.
227
+ clearInterval(timer);
228
+ timer = undefined;
229
+ return;
230
+ }
231
+ const at = new Date();
232
+ await utimes(lockPath, at, at).catch(() => { });
233
+ })();
234
+ }, intervalMs);
235
+ timer.unref?.();
236
+ return {
237
+ async release() {
238
+ if (timer)
239
+ clearInterval(timer);
240
+ timer = undefined;
241
+ // Only remove the lock if it's still ours — defends against a
242
+ // narrow race where another process reclaimed our stale lock.
243
+ if (isOurs(await readHolder(lockPath))) {
244
+ await rm(lockPath, { force: true }).catch(() => { });
245
+ }
246
+ },
247
+ };
248
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Memory and CPU as this process may actually use them. `os.totalmem()` and
3
+ * `os.availableParallelism()` describe the host; inside a container started
4
+ * with `--memory 8g --cpus 4` on a 64 GB, 16-core machine they still say 64
5
+ * and 16, so a model sized from them gets the container killed by its own
6
+ * cgroup. Every sizing decision reads these instead.
7
+ */
8
+ export interface HostLimitsProbe {
9
+ totalmem: () => number;
10
+ freemem: () => number;
11
+ constrainedMemory: () => number | undefined;
12
+ availableMemory: () => number | undefined;
13
+ availableParallelism: () => number;
14
+ /** Every logical CPU the host has, whatever this process may use. */
15
+ hostCpuCount: () => number;
16
+ readFile: (path: string) => string | undefined;
17
+ }
18
+ /**
19
+ * The cgroup memory limit when it is below the host's RAM, else null.
20
+ * Node reads cgroup v1 and v2; "no limit" comes back as 0 or as a value at
21
+ * or above physical RAM, depending on the libuv release.
22
+ */
23
+ export declare function containerMemoryLimitBytes(probe?: HostLimitsProbe): number | null;
24
+ /** RAM this process may use: host RAM, or the container's limit when lower. */
25
+ export declare function totalMemoryBytes(probe?: HostLimitsProbe): number;
26
+ /** Free RAM, never more than the container's limit leaves. */
27
+ export declare function freeMemoryBytes(probe?: HostLimitsProbe): number;
28
+ /**
29
+ * Caps a host-wide free-memory reading (`MemAvailable`, `vm_stat`) to what
30
+ * the container still has. Unchanged outside a container.
31
+ */
32
+ export declare function capToContainer(hostFreeBytes: number, probe?: HostLimitsProbe): number;
33
+ /**
34
+ * CPUs this process may keep busy: the scheduler's CPU set, further capped
35
+ * by a cgroup v2 `cpu.max` quota (or v1 `cfs_quota_us`) rounded up.
36
+ */
37
+ export declare function availableCpuCount(probe?: HostLimitsProbe): number;
38
+ export declare function cgroupCpuQuota(probe?: HostLimitsProbe): number | null;
39
+ /**
40
+ * The CPUs a container (a `--cpus` quota or a `--cpuset-cpus` set) leaves this
41
+ * process when that is fewer than the host has, else null. Engines that size
42
+ * their own thread pool from the host's cores, as llama.cpp does, oversubscribe
43
+ * such a limit badly: 32 threads under a 4-CPU quota spend most of each period
44
+ * throttled. Measured in a 4-CPU container: a 0.8B model decoded at 3.7 t/s.
45
+ */
46
+ export declare function containerCpuLimit(probe?: HostLimitsProbe): number | null;