@bendyline/gezel-inference 1.0.7 → 1.0.8

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.
@@ -39,9 +39,10 @@ export declare function applyCallableToolGrammar(body: Record<string, unknown>,
39
39
  *
40
40
  * This deliberately checks presence only. A present-but-wrongly-typed value
41
41
  * remains the MCP validator's job; the MLX Hermes grammar promises only that
42
- * every declared required key is emitted before the call can close. Seeing a
43
- * missing key is therefore evidence that the live constrained-decoding path
44
- * broke its own contract, not an ordinary model validation miss.
42
+ * every declared required key is emitted before a constrained call can close.
43
+ * A missing key alone does not establish a decoder fault: calls salvaged from
44
+ * ordinary prose were not subject to that grammar. The caller must preserve
45
+ * their origin when deciding whether to disable speculative decoding.
45
46
  */
46
47
  export declare function missingTopLevelRequiredToolArgs(schema: Record<string, unknown> | undefined, args: Record<string, unknown>): string[];
47
48
  export declare function hermesRequiredArgGrammarRequested(body: Record<string, unknown>): boolean;
@@ -59,9 +59,10 @@ export function applyCallableToolGrammar(body, bridges) {
59
59
  *
60
60
  * This deliberately checks presence only. A present-but-wrongly-typed value
61
61
  * remains the MCP validator's job; the MLX Hermes grammar promises only that
62
- * every declared required key is emitted before the call can close. Seeing a
63
- * missing key is therefore evidence that the live constrained-decoding path
64
- * broke its own contract, not an ordinary model validation miss.
62
+ * every declared required key is emitted before a constrained call can close.
63
+ * A missing key alone does not establish a decoder fault: calls salvaged from
64
+ * ordinary prose were not subject to that grammar. The caller must preserve
65
+ * their origin when deciding whether to disable speculative decoding.
65
66
  */
66
67
  export function missingTopLevelRequiredToolArgs(schema, args) {
67
68
  const required = schema?.required;
@@ -1,5 +1,6 @@
1
1
  import type { NativeCapacityCommand, NativeCapacityReply } from '@bendyline/gezel';
2
- import { readProcessIdentities } from './process-identity.js';
2
+ import { ProcessIdentityCache, processAlive, type readProcessIdentities } from './process-identity.js';
3
+ export { processAlive };
3
4
  export interface DeviceCapacitySample {
4
5
  budgetBytes: number;
5
6
  gpuBudgetBytes: number;
@@ -14,7 +15,6 @@ export interface DeviceCapacitySample {
14
15
  availableRamBytes?: number;
15
16
  serializeLoads: boolean;
16
17
  }
17
- export declare function processAlive(pid: number): boolean;
18
18
  /**
19
19
  * SQLite's process lock makes check-and-reserve atomic across GEZEL_HOME values.
20
20
  * Reservations survive a broker restart. A dead parent does not free the memory
@@ -22,11 +22,13 @@ export declare function processAlive(pid: number): boolean;
22
22
  */
23
23
  export declare class DeviceCapacityLedger {
24
24
  private readonly options;
25
+ private readonly identityCache;
25
26
  constructor(options: {
26
27
  directory: string;
27
28
  sample: () => Promise<DeviceCapacitySample>;
28
29
  alive?: (pid: number) => boolean;
29
30
  identities?: typeof readProcessIdentities;
31
+ identityCache?: ProcessIdentityCache;
30
32
  });
31
33
  execute(command: NativeCapacityCommand): Promise<NativeCapacityReply>;
32
34
  private transact;
@@ -2,7 +2,8 @@ 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
+ import { ProcessIdentityCache, processAlive, sharedProcessIdentities, } from './process-identity.js';
6
+ export { processAlive };
6
7
  // Keep the newer builtin behind a runtime require. The current tsup/esbuild
7
8
  // target rewrites a static `node:sqlite` import to the nonexistent npm package
8
9
  // `sqlite`, making the daemon bundle fail before it can write runtime state.
@@ -11,15 +12,6 @@ const { DatabaseSync } = nodeRequire('node:sqlite');
11
12
  function gb(bytes) {
12
13
  return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
13
14
  }
14
- export function processAlive(pid) {
15
- try {
16
- process.kill(pid, 0);
17
- return true;
18
- }
19
- catch (error) {
20
- return error.code !== 'ESRCH';
21
- }
22
- }
23
15
  /**
24
16
  * SQLite's process lock makes check-and-reserve atomic across GEZEL_HOME values.
25
17
  * Reservations survive a broker restart. A dead parent does not free the memory
@@ -27,8 +19,18 @@ export function processAlive(pid) {
27
19
  */
28
20
  export class DeviceCapacityLedger {
29
21
  options;
22
+ identityCache;
30
23
  constructor(options) {
31
24
  this.options = options;
25
+ // An injected reader still goes through a cache, so tests see the reads
26
+ // production makes. An injected `alive` alone checks liveness only.
27
+ this.identityCache =
28
+ options.identityCache ??
29
+ (options.identities
30
+ ? new ProcessIdentityCache({ read: options.identities, alive: options.alive })
31
+ : options.alive
32
+ ? undefined
33
+ : sharedProcessIdentities);
32
34
  }
33
35
  async execute(command) {
34
36
  const sample = await this.options.sample();
@@ -43,18 +45,31 @@ export class DeviceCapacityLedger {
43
45
  .prepare('SELECT claim FROM claims')
44
46
  .all()
45
47
  .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);
48
+ const record = [];
49
+ if (command.action === 'acquire' && !snapshot.some((claim) => claim.id === command.id))
50
+ record.push(command.ownerPid);
52
51
  if (command.action === 'bind')
53
- pids.push(command.childPid);
52
+ record.push(command.childPid);
53
+ // Stamps only decide which reservations are stale, and a stale one costs
54
+ // someone only while a request waits for memory. With no request
55
+ // waiting, liveness alone prunes, so the status check that runs before
56
+ // every engine request reads no stamps.
57
+ const confirm = new Map();
58
+ const stamp = (pid, identity) => {
59
+ if (pid !== undefined && identity !== undefined)
60
+ confirm.set(pid, [...(confirm.get(pid) ?? []), identity]);
61
+ };
62
+ if (command.action === 'acquire' || snapshot.some((claim) => claim.phase === 'waiting')) {
63
+ for (const claim of snapshot) {
64
+ stamp(claim.childPid, claim.childIdentity);
65
+ // An owner's stamp is only consulted while its claim is queued.
66
+ if (claim.phase === 'waiting')
67
+ stamp(claim.ownerPid, claim.ownerIdentity);
68
+ }
69
+ }
54
70
  // 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())
71
+ const identities = command.action !== 'release' && this.identityCache
72
+ ? await this.identityCache.lookup(record, confirm).catch(() => new Map())
58
73
  : new Map();
59
74
  db.exec('BEGIN IMMEDIATE');
60
75
  try {
@@ -1,2 +1,40 @@
1
+ export declare function processAlive(pid: number): boolean;
1
2
  /** Birth stamps survive coordinator restarts and distinguish a reused PID. */
2
3
  export declare function readProcessIdentities(pids: readonly number[]): Promise<ReadonlyMap<number, string>>;
4
+ /**
5
+ * How long a stamp read for a still-live PID is trusted. A PID can die and be
6
+ * reused between two liveness checks; this bounds how long such a dead
7
+ * engine's reservation outlives it. That is the safe direction: memory looks
8
+ * taken for a few seconds longer, never free twice.
9
+ */
10
+ export declare const VERIFIED_IDENTITY_TTL_MS = 10000;
11
+ export interface ProcessIdentityCacheOptions {
12
+ read?: (pids: readonly number[]) => Promise<ReadonlyMap<number, string>>;
13
+ alive?: (pid: number) => boolean;
14
+ now?: () => number;
15
+ ttlMs?: number;
16
+ }
17
+ /**
18
+ * Reading a birth stamp starts `ps`, or PowerShell on Windows. The capacity
19
+ * ledger used to read every PID it holds on each command, which ran before
20
+ * every engine request. A stamp read for a PID that is still alive is reused
21
+ * for a short while, and the PIDs that do need reading go in one batch.
22
+ *
23
+ * A cached stamp may only confirm the stamp a reservation already recorded.
24
+ * A stamp about to be recorded, or one that disagrees, is read again: another
25
+ * coordinator may have recorded a newer process at that PID, and trusting an
26
+ * older stamp there would free a live engine's memory.
27
+ */
28
+ export declare class ProcessIdentityCache {
29
+ private readonly options;
30
+ private readonly verified;
31
+ constructor(options?: ProcessIdentityCacheOptions);
32
+ /**
33
+ * `record`: PIDs whose stamp is about to be written onto a reservation.
34
+ * `confirm`: each PID's stamps as reservations recorded them.
35
+ * PIDs that are dead, or whose stamp cannot be read, are left out.
36
+ */
37
+ lookup(record: Iterable<number>, confirm: ReadonlyMap<number, readonly string[]>): Promise<ReadonlyMap<number, string>>;
38
+ }
39
+ /** One cache per process: every ledger in it asks about the same OS processes. */
40
+ export declare const sharedProcessIdentities: ProcessIdentityCache;
@@ -11,6 +11,15 @@ const PROBE_PID_LIMIT = {
11
11
  darwin: 99_999,
12
12
  win32: 2_147_483_647,
13
13
  };
14
+ export function processAlive(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch (error) {
20
+ return error.code !== 'ESRCH';
21
+ }
22
+ }
14
23
  /** Birth stamps survive coordinator restarts and distinguish a reused PID. */
15
24
  export async function readProcessIdentities(pids) {
16
25
  const limit = PROBE_PID_LIMIT[process.platform] ?? Number.MAX_SAFE_INTEGER;
@@ -68,3 +77,80 @@ export async function readProcessIdentities(pids) {
68
77
  }
69
78
  return identities;
70
79
  }
80
+ /**
81
+ * How long a stamp read for a still-live PID is trusted. A PID can die and be
82
+ * reused between two liveness checks; this bounds how long such a dead
83
+ * engine's reservation outlives it. That is the safe direction: memory looks
84
+ * taken for a few seconds longer, never free twice.
85
+ */
86
+ export const VERIFIED_IDENTITY_TTL_MS = 10_000;
87
+ /**
88
+ * Reading a birth stamp starts `ps`, or PowerShell on Windows. The capacity
89
+ * ledger used to read every PID it holds on each command, which ran before
90
+ * every engine request. A stamp read for a PID that is still alive is reused
91
+ * for a short while, and the PIDs that do need reading go in one batch.
92
+ *
93
+ * A cached stamp may only confirm the stamp a reservation already recorded.
94
+ * A stamp about to be recorded, or one that disagrees, is read again: another
95
+ * coordinator may have recorded a newer process at that PID, and trusting an
96
+ * older stamp there would free a live engine's memory.
97
+ */
98
+ export class ProcessIdentityCache {
99
+ options;
100
+ verified = new Map();
101
+ constructor(options = {}) {
102
+ this.options = options;
103
+ }
104
+ /**
105
+ * `record`: PIDs whose stamp is about to be written onto a reservation.
106
+ * `confirm`: each PID's stamps as reservations recorded them.
107
+ * PIDs that are dead, or whose stamp cannot be read, are left out.
108
+ */
109
+ async lookup(record, confirm) {
110
+ const alive = this.options.alive ?? processAlive;
111
+ const ttl = this.options.ttlMs ?? VERIFIED_IDENTITY_TTL_MS;
112
+ const now = (this.options.now ?? (() => performance.now()))();
113
+ for (const [pid, entry] of this.verified) {
114
+ if (now - entry.at >= ttl)
115
+ this.verified.delete(pid);
116
+ }
117
+ const live = (pid) => {
118
+ if (alive(pid))
119
+ return true;
120
+ // Forget a PID seen dead, so whatever process takes it next is read.
121
+ this.verified.delete(pid);
122
+ return false;
123
+ };
124
+ const identities = new Map();
125
+ const unread = new Set();
126
+ for (const pid of record)
127
+ if (live(pid))
128
+ unread.add(pid);
129
+ for (const [pid, stamps] of confirm) {
130
+ if (unread.has(pid) || !live(pid))
131
+ continue;
132
+ const cached = this.verified.get(pid)?.identity;
133
+ if (cached !== undefined && stamps.every((stamp) => stamp === cached)) {
134
+ identities.set(pid, cached);
135
+ }
136
+ else {
137
+ unread.add(pid);
138
+ }
139
+ }
140
+ if (unread.size === 0)
141
+ return identities;
142
+ const read = await (this.options.read ?? readProcessIdentities)([...unread]).catch(() => new Map());
143
+ for (const pid of unread) {
144
+ const identity = read.get(pid);
145
+ if (identity === undefined) {
146
+ this.verified.delete(pid);
147
+ continue;
148
+ }
149
+ this.verified.set(pid, { identity, at: now });
150
+ identities.set(pid, identity);
151
+ }
152
+ return identities;
153
+ }
154
+ }
155
+ /** One cache per process: every ledger in it asks about the same OS processes. */
156
+ export const sharedProcessIdentities = new ProcessIdentityCache();
@@ -1,3 +1,6 @@
1
+ import { type LockIdentity, currentLockIdentity, parseLockFile, procStartTicks, sharesPidNamespace } from '@bendyline/gezel/native';
2
+ export type { LockIdentity };
3
+ export { currentLockIdentity, parseLockFile, procStartTicks, sharesPidNamespace };
1
4
  /**
2
5
  * Thrown when a gezel home already has a live daemon. The CLI's `gezel
3
6
  * start` / the supervisor surface this as an actionable message rather
@@ -12,33 +15,6 @@ export declare class SingleInstanceError extends Error {
12
15
  export interface RuntimeLock {
13
16
  release(): Promise<void>;
14
17
  }
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;
42
18
  /**
43
19
  * Acquire an exclusive single-instance lock for a gezel home so two daemons
44
20
  * can't share one `~/.gezel/` and race its on-disk state (config, sessions,
@@ -1,6 +1,6 @@
1
- import { readFileSync, readlinkSync } from 'node:fs';
2
1
  import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
3
- import { hostname as osHostname } from 'node:os';
2
+ import { LOCK_HEARTBEAT_INTERVAL_MS, LOCK_STALE_AFTER_MS, currentLockIdentity, foreignHolderMessage, parseLockFile, procStartTicks, sharesPidNamespace, } from '@bendyline/gezel/native';
3
+ export { currentLockIdentity, parseLockFile, procStartTicks, sharesPidNamespace };
4
4
  /**
5
5
  * Thrown when a gezel home already has a live daemon. The CLI's `gezel
6
6
  * start` / the supervisor surface this as an actionable message rather
@@ -12,7 +12,7 @@ export class SingleInstanceError extends Error {
12
12
  holderHost;
13
13
  constructor(holderPid, lockPath, holderHost) {
14
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.`
15
+ ? foreignHolderMessage({ pid: holderPid, hostname: holderHost })
16
16
  : holderPid > 0
17
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
18
  : `Could not acquire the single-instance lock at ${lockPath}.`);
@@ -22,8 +22,8 @@ export class SingleInstanceError extends Error {
22
22
  this.name = 'SingleInstanceError';
23
23
  }
24
24
  }
25
- const HEARTBEAT_INTERVAL_MS = 10_000;
26
- const STALE_AFTER_MS = 40_000;
25
+ // A holder's identity (core's lock-identity.ts) is what let a one-shot
26
+ // `docker run … gezel do` stop reclaiming a serving container's live lock.
27
27
  function defaultIsAlive(pid) {
28
28
  try {
29
29
  process.kill(pid, 0);
@@ -35,45 +35,6 @@ function defaultIsAlive(pid) {
35
35
  return err.code === 'EPERM';
36
36
  }
37
37
  }
38
- function readTrimmed(path) {
39
- try {
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
38
  /**
78
39
  * The pid stays on the first line so a daemon from before the identity
79
40
  * record (which parses the leading integer) still reads it as held instead
@@ -82,29 +43,6 @@ export function currentLockIdentity() {
82
43
  function serializeIdentity(identity) {
83
44
  return `${identity.pid}\n${JSON.stringify({ v: 1, ...identity })}\n`;
84
45
  }
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
46
  async function readHolder(lockPath) {
109
47
  try {
110
48
  return parseLockFile(await readFile(lockPath, 'utf8'));
@@ -113,17 +51,6 @@ async function readHolder(lockPath) {
113
51
  return null;
114
52
  }
115
53
  }
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
54
  async function lockMtimeMs(lockPath) {
128
55
  try {
129
56
  return (await stat(lockPath)).mtimeMs;
@@ -174,7 +101,7 @@ export async function acquireSingleInstanceLock(opts) {
174
101
  const startTicksOf = opts.startTicksOf ?? procStartTicks;
175
102
  const now = opts.now ?? Date.now;
176
103
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
177
- const staleAfterMs = opts.staleAfterMs ?? STALE_AFTER_MS;
104
+ const staleAfterMs = opts.staleAfterMs ?? LOCK_STALE_AFTER_MS;
178
105
  await mkdir(opts.runtimeDir, { recursive: true });
179
106
  for (let attempt = 0; attempt < 3; attempt++) {
180
107
  try {
@@ -185,7 +112,7 @@ export async function acquireSingleInstanceLock(opts) {
185
112
  finally {
186
113
  await handle.close();
187
114
  }
188
- return holdLock(opts.lockPath, me, opts.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS);
115
+ return holdLock(opts.lockPath, me, opts.heartbeatIntervalMs ?? LOCK_HEARTBEAT_INTERVAL_MS);
189
116
  }
190
117
  catch (err) {
191
118
  if (err.code !== 'EEXIST')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/gezel-inference",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Gezel's on-device inference runtime: native engines, local model management, embeddings, knowledge search, and OpenAI-compatible routes.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -73,9 +73,9 @@
73
73
  "!dist/**/__pycache__"
74
74
  ],
75
75
  "dependencies": {
76
- "@bendyline/gezel": "1.2.12",
77
- "@bendyline/gezel-catalog": "1.2.12",
78
- "@bendyline/gezel-client": "1.2.12",
76
+ "@bendyline/gezel": "1.2.13",
77
+ "@bendyline/gezel-catalog": "1.2.13",
78
+ "@bendyline/gezel-client": "1.2.13",
79
79
  "@bendyline/gezel-knowledge": "1.2.10",
80
80
  "hono": "4.13.14",
81
81
  "tar": "7.5.21",