@pasko70/pibo 1.11.1 → 1.11.2

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.
@@ -4,11 +4,13 @@ import { promisify } from "node:util";
4
4
  import { getHeapStatistics } from "node:v8";
5
5
  const execFileAsync = promisify(execFile);
6
6
  const DEFAULT_POLICY = Object.freeze({
7
- mode: "warn",
7
+ mode: "block",
8
8
  minFreeMemoryBytes: 256 * 1024 * 1024,
9
9
  minHeapAvailableBytes: 64 * 1024 * 1024,
10
10
  maxRssBytes: 1536 * 1024 * 1024,
11
11
  knownDaemonWarningRssBytes: 2 * 1024 * 1024 * 1024,
12
+ maxConcurrentYieldedRuns: 1,
13
+ yieldedRunMemoryReservationBytes: 2 * 1024 * 1024 * 1024,
12
14
  });
13
15
  export function resolveGatewayResourceGuardPolicy(env = process.env) {
14
16
  return {
@@ -17,6 +19,8 @@ export function resolveGatewayResourceGuardPolicy(env = process.env) {
17
19
  minHeapAvailableBytes: parseByteThreshold(env.PIBO_GATEWAY_MIN_HEAP_AVAILABLE_BYTES, DEFAULT_POLICY.minHeapAvailableBytes),
18
20
  maxRssBytes: parseByteThreshold(env.PIBO_GATEWAY_MAX_RSS_BYTES, DEFAULT_POLICY.maxRssBytes),
19
21
  knownDaemonWarningRssBytes: parseByteThreshold(env.PIBO_GATEWAY_KNOWN_DAEMON_WARNING_RSS_BYTES, DEFAULT_POLICY.knownDaemonWarningRssBytes),
22
+ maxConcurrentYieldedRuns: parsePositiveInteger(env.PIBO_GATEWAY_MAX_CONCURRENT_YIELDED_RUNS, DEFAULT_POLICY.maxConcurrentYieldedRuns),
23
+ yieldedRunMemoryReservationBytes: parseByteThreshold(env.PIBO_GATEWAY_YIELDED_RUN_MEMORY_RESERVATION_BYTES, DEFAULT_POLICY.yieldedRunMemoryReservationBytes),
20
24
  };
21
25
  }
22
26
  export function collectGatewayProcessMemory() {
@@ -74,8 +78,39 @@ export function assertGatewayResourceAvailableForWork(workLabel, env = process.e
74
78
  const snapshot = buildGatewayResourceSnapshot({ env, includeProcesses: false });
75
79
  if (snapshot.guardAction !== "block")
76
80
  return;
77
- const reasons = snapshot.checks.filter((check) => check.severity === "critical").map((check) => check.message).join("; ");
78
- throw new Error(`Gateway resource guard blocked ${workLabel} before starting: ${reasons}`);
81
+ throwGatewayResourceBlock(workLabel, snapshot.checks.filter((check) => check.severity === "critical").map((check) => check.message));
82
+ }
83
+ export class GatewayWorkAdmissionController {
84
+ activeReservations = new Set();
85
+ reserve(workLabel, env = process.env) {
86
+ const snapshot = buildGatewayResourceSnapshot({ env, includeProcesses: false });
87
+ const policy = snapshot.policy;
88
+ if (snapshot.guardAction === "block") {
89
+ throwGatewayResourceBlock(workLabel, snapshot.checks.filter((check) => check.severity === "critical").map((check) => check.message));
90
+ }
91
+ if (policy.mode === "block" && this.activeReservations.size >= policy.maxConcurrentYieldedRuns) {
92
+ throwGatewayResourceBlock(workLabel, [
93
+ `Active yielded runs ${this.activeReservations.size} reached the configured limit ${policy.maxConcurrentYieldedRuns}. Wait for an active run to settle or raise PIBO_GATEWAY_MAX_CONCURRENT_YIELDED_RUNS explicitly.`,
94
+ ]);
95
+ }
96
+ if (policy.mode === "block" &&
97
+ snapshot.host.freeBytes < policy.minFreeMemoryBytes + policy.yieldedRunMemoryReservationBytes) {
98
+ throwGatewayResourceBlock(workLabel, [
99
+ `Host free memory ${snapshot.host.freeBytes} cannot preserve reserve ${policy.minFreeMemoryBytes} after the yielded-run reservation ${policy.yieldedRunMemoryReservationBytes}.`,
100
+ ]);
101
+ }
102
+ const reservation = Symbol(workLabel);
103
+ this.activeReservations.add(reservation);
104
+ let released = false;
105
+ return {
106
+ release: () => {
107
+ if (released)
108
+ return;
109
+ released = true;
110
+ this.activeReservations.delete(reservation);
111
+ },
112
+ };
113
+ }
79
114
  }
80
115
  export function parseHostProcessResourceList(output, gatewayPid, policy = DEFAULT_POLICY) {
81
116
  const rows = [];
@@ -106,7 +141,7 @@ export function renderGatewayResourceSnapshotText(snapshot) {
106
141
  lines.push(`Gateway PID: ${snapshot.gateway.pid}`);
107
142
  lines.push(`Gateway memory: rss=${snapshot.gateway.rssBytes} heapUsed=${snapshot.gateway.heapUsedBytes} heapAvailable=${snapshot.gateway.heapAvailableBytes} heapLimit=${snapshot.gateway.heapLimitBytes}`);
108
143
  lines.push(`Host memory: free=${snapshot.host.freeBytes} total=${snapshot.host.totalBytes}`);
109
- lines.push(`Thresholds: minFree=${snapshot.policy.minFreeMemoryBytes} minHeapAvailable=${snapshot.policy.minHeapAvailableBytes} maxRss=${snapshot.policy.maxRssBytes} daemonWarnRss=${snapshot.policy.knownDaemonWarningRssBytes}`);
144
+ lines.push(`Thresholds: minFree=${snapshot.policy.minFreeMemoryBytes} minHeapAvailable=${snapshot.policy.minHeapAvailableBytes} maxRss=${snapshot.policy.maxRssBytes} daemonWarnRss=${snapshot.policy.knownDaemonWarningRssBytes} maxYieldedRuns=${snapshot.policy.maxConcurrentYieldedRuns} yieldedRunReservation=${snapshot.policy.yieldedRunMemoryReservationBytes}`);
110
145
  lines.push(`Related processes: children=${snapshot.processes.children.length} knownDaemons=${snapshot.processes.knownDaemons.length} processList=${snapshot.processes.available ? "available" : "unavailable"}`);
111
146
  if (snapshot.processes.error)
112
147
  lines.push(`Process list error: ${snapshot.processes.error}`);
@@ -160,11 +195,13 @@ function processResultFromOptions(gatewayPid, options, policy) {
160
195
  }
161
196
  function parseMode(value, fallback) {
162
197
  const normalized = value?.trim().toLowerCase();
198
+ if (normalized === undefined || normalized === "")
199
+ return fallback;
163
200
  if (normalized === "off" || normalized === "0" || normalized === "false")
164
201
  return "off";
165
202
  if (normalized === "block" || normalized === "strict")
166
203
  return "block";
167
- if (normalized === "warn" || normalized === "1" || normalized === "true" || normalized === undefined || normalized === "")
204
+ if (normalized === "warn" || normalized === "1" || normalized === "true")
168
205
  return "warn";
169
206
  return fallback;
170
207
  }
@@ -174,6 +211,15 @@ function parseByteThreshold(value, fallback) {
174
211
  const parsed = Number(value);
175
212
  return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
176
213
  }
214
+ function parsePositiveInteger(value, fallback) {
215
+ if (value === undefined || value.trim() === "")
216
+ return fallback;
217
+ const parsed = Number(value);
218
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
219
+ }
220
+ function throwGatewayResourceBlock(workLabel, reasons) {
221
+ throw new Error(`Gateway resource guard blocked ${workLabel} before starting: ${reasons.join("; ")}`);
222
+ }
177
223
  function knownDaemonLabel(commandName, args) {
178
224
  const combined = `${commandName} ${args}`;
179
225
  if (/comfyui|main\.py.*--port\s+8188/i.test(combined))
@@ -16,7 +16,7 @@ import { loadPiboUserSettings } from "./user-settings.js";
16
16
  import { resolvePiboSessionActiveModel } from "./session-model.js";
17
17
  import { isPiboThinkingLevel } from "./thinking.js";
18
18
  import { RuntimeSessionRegistry } from "../tools/runtime/registry.js";
19
- import { assertGatewayResourceAvailableForWork } from "./gateway-resource-guard.js";
19
+ import { GatewayWorkAdmissionController } from "./gateway-resource-guard.js";
20
20
  import { withWorkflowSessionKind } from "../sessions/workflow-session-kind.js";
21
21
  import { PiboRuntimeTelemetryRecorder } from "./runtime-telemetry.js";
22
22
  import { createPiboProviderTelemetryExtension } from "./provider-telemetry.js";
@@ -143,6 +143,7 @@ export class PiboSessionRouter {
143
143
  pendingSessions = new Map();
144
144
  listeners = new Set();
145
145
  runRegistry;
146
+ gatewayWorkAdmission = new GatewayWorkAdmissionController();
146
147
  signalRegistry;
147
148
  runtimeRegistry;
148
149
  scheduledRunReminders = new Map();
@@ -697,17 +698,24 @@ export class PiboSessionRouter {
697
698
  createRunToolController(parentPiboSessionId) {
698
699
  return {
699
700
  startToolRun: ({ toolName, params, completionPolicy, retryable, maxAttempts, timeoutMs, serviceWarning, execute }) => {
700
- assertGatewayResourceAvailableForWork(`yielded run ${toolName}`);
701
- const run = this.runRegistry.startToolRun({
702
- controllerPiboSessionId: parentPiboSessionId,
703
- toolName,
704
- params,
705
- completionPolicy,
706
- retryable,
707
- maxAttempts,
708
- timeoutMs,
709
- serviceWarning,
710
- });
701
+ const admission = this.gatewayWorkAdmission.reserve(`yielded run ${toolName}`);
702
+ let run;
703
+ try {
704
+ run = this.runRegistry.startToolRun({
705
+ controllerPiboSessionId: parentPiboSessionId,
706
+ toolName,
707
+ params,
708
+ completionPolicy,
709
+ retryable,
710
+ maxAttempts,
711
+ timeoutMs,
712
+ serviceWarning,
713
+ });
714
+ }
715
+ catch (error) {
716
+ admission.release();
717
+ throw error;
718
+ }
711
719
  void (async () => {
712
720
  try {
713
721
  const result = await execute();
@@ -723,6 +731,9 @@ export class PiboSessionRouter {
723
731
  if (terminalRun)
724
732
  this.scheduleRunReminder(parentPiboSessionId, false);
725
733
  }
734
+ finally {
735
+ admission.release();
736
+ }
726
737
  })();
727
738
  return run;
728
739
  },
@@ -1058,11 +1058,13 @@ Reports:
1058
1058
  Gateway RSS/heap headroom, host free-memory reserve, direct child processes, and known heavy local daemons such as ComfyUI or Unity when process listing is available.
1059
1059
 
1060
1060
  Environment:
1061
- PIBO_GATEWAY_RESOURCE_GUARD=warn|block|off
1061
+ PIBO_GATEWAY_RESOURCE_GUARD=block|warn|off (default: block)
1062
1062
  PIBO_GATEWAY_MIN_FREE_MEMORY_BYTES=<bytes>
1063
1063
  PIBO_GATEWAY_MIN_HEAP_AVAILABLE_BYTES=<bytes>
1064
1064
  PIBO_GATEWAY_MAX_RSS_BYTES=<bytes>
1065
1065
  PIBO_GATEWAY_KNOWN_DAEMON_WARNING_RSS_BYTES=<bytes>
1066
+ PIBO_GATEWAY_MAX_CONCURRENT_YIELDED_RUNS=<count> (default: 1)
1067
+ PIBO_GATEWAY_YIELDED_RUN_MEMORY_RESERVATION_BYTES=<bytes> (default: 2147483648)
1066
1068
 
1067
1069
  Next:
1068
1070
  pibo debug resources --json
@@ -500,7 +500,7 @@ export class PiboReliabilityStore {
500
500
  ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?)
501
501
  `)
502
502
  .run(runId, input.controllerPiboSessionId, input.completionPolicy, input.toolName, `${input.toolName} run is running.`, timestamp, timestamp, job.jobId, input.retryable ? 1 : 0, maxAttempts, input.timeoutMs ?? null, timeoutAt ?? null, input.serviceWarning ?? null);
503
- this.claimJob(job.jobId, `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
503
+ this.claimJob(job.jobId, input.workerId ?? `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
504
504
  return this.requireRun(runId);
505
505
  }
506
506
  updateRun(runId, patch) {
@@ -572,14 +572,27 @@ export class PiboReliabilityStore {
572
572
  const result = this.db.prepare(`DELETE FROM pibo_runs WHERE run_id IN (${placeholders})`).run(...ids);
573
573
  return Number(result.changes ?? 0);
574
574
  }
575
- recoverInterruptedRuns() {
575
+ recoverInterruptedRuns(workerId = `run-registry:${process.pid}`) {
576
576
  const rows = this.db.prepare("SELECT * FROM pibo_runs WHERE status = 'running'").all();
577
577
  const recovered = [];
578
578
  const timestamp = now();
579
579
  for (const row of rows) {
580
- if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp))
580
+ if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp, workerId))
581
581
  continue;
582
582
  const run = runFromRow(row);
583
+ if (run.timeoutAt && run.timeoutAt <= timestamp) {
584
+ const error = `Run deadline ${run.timeoutAt} elapsed before the interrupted runtime recovered.`;
585
+ if (run.jobId)
586
+ this.moveLiveJobToDead(run.jobId, error, "timeout", timestamp);
587
+ recovered.push(this.updateRun(run.runId, {
588
+ status: "timed_out",
589
+ error,
590
+ timeoutPhase: "lifetime",
591
+ summary: `${run.toolName} run started successfully, then reached its configured timeout.`,
592
+ completedAt: timestamp,
593
+ }) ?? run);
594
+ continue;
595
+ }
583
596
  if (run.retryable && run.maxAttempts > 1) {
584
597
  if (run.jobId)
585
598
  this.releaseJobForRetry(run.jobId, timestamp);
@@ -653,16 +666,17 @@ export class PiboReliabilityStore {
653
666
  for (const row of expired)
654
667
  this.moveJobToDead(row, row.last_error ?? "Job expired.", "expired", timestamp);
655
668
  }
656
- hasUnexpiredJobClaim(jobId, timestamp) {
669
+ hasUnexpiredJobClaim(jobId, timestamp, workerId) {
657
670
  const row = this.db
658
671
  .prepare(`
659
672
  SELECT job_id FROM pibo_jobs
660
673
  WHERE job_id = ?
661
674
  AND state = 'running'
675
+ AND worker_id = ?
662
676
  AND claim_expires_at IS NOT NULL
663
677
  AND claim_expires_at > ?
664
678
  `)
665
- .get(jobId, timestamp);
679
+ .get(jobId, workerId, timestamp);
666
680
  return row !== undefined;
667
681
  }
668
682
  releaseJobForRetry(jobId, timestamp) {
@@ -46,14 +46,16 @@ export class PiboRunRegistry {
46
46
  runs = new Map();
47
47
  waiters = new Map();
48
48
  listeners = new Set();
49
+ workerId;
49
50
  subscribe(listener) {
50
51
  this.listeners.add(listener);
51
52
  return () => this.listeners.delete(listener);
52
53
  }
53
54
  constructor(options = {}) {
54
55
  this.options = options;
56
+ this.workerId = options.workerId ?? `run-registry:${process.pid}:${randomUUID()}`;
55
57
  if (this.options.store) {
56
- this.options.store.recoverInterruptedRuns();
58
+ this.options.store.recoverInterruptedRuns(this.workerId);
57
59
  for (const record of this.options.store.listRuns({ includeConsumed: true, includeDetached: true })) {
58
60
  this.runs.set(record.runId, recordFromStored(record));
59
61
  }
@@ -71,6 +73,7 @@ export class PiboRunRegistry {
71
73
  maxAttempts: input.maxAttempts ?? 1,
72
74
  timeoutMs: input.timeoutMs,
73
75
  serviceWarning: input.serviceWarning,
76
+ workerId: this.workerId,
74
77
  });
75
78
  const record = recordFromStored(stored);
76
79
  this.runs.set(record.runId, record);
@@ -112,7 +115,7 @@ export class PiboRunRegistry {
112
115
  this.finish(record);
113
116
  this.options.store?.updateRun(runId, record);
114
117
  if (record.jobId)
115
- this.options.store?.ack(record.jobId, `run-registry:${process.pid}`);
118
+ this.options.store?.ack(record.jobId, this.workerId);
116
119
  const output = snapshot(record);
117
120
  this.notify({ type: "run_changed", run: output, previousStatus });
118
121
  return output;
@@ -128,7 +131,7 @@ export class PiboRunRegistry {
128
131
  this.finish(record);
129
132
  this.options.store?.updateRun(runId, record);
130
133
  if (record.jobId)
131
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, error);
134
+ this.options.store?.fail(record.jobId, this.workerId, error);
132
135
  const output = snapshot(record);
133
136
  this.notify({ type: "run_changed", run: output, previousStatus, reason: error });
134
137
  return output;
@@ -147,7 +150,7 @@ export class PiboRunRegistry {
147
150
  this.finish(record);
148
151
  this.options.store?.updateRun(runId, record);
149
152
  if (record.jobId)
150
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, error);
153
+ this.options.store?.fail(record.jobId, this.workerId, error);
151
154
  const output = snapshot(record);
152
155
  this.notify({ type: "run_changed", run: output, previousStatus, reason: error });
153
156
  return output;
@@ -227,7 +230,7 @@ export class PiboRunRegistry {
227
230
  record.summary = `${record.toolName} run cancelled.`;
228
231
  this.finish(record);
229
232
  if (record.jobId)
230
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, "Run was cancelled.");
233
+ this.options.store?.fail(record.jobId, this.workerId, "Run was cancelled.");
231
234
  }
232
235
  record.consumed = true;
233
236
  record.updatedAt = now();
@@ -292,7 +295,7 @@ export class PiboRunRegistry {
292
295
  this.finish(record);
293
296
  this.options.store?.updateRun(record.runId, record);
294
297
  if (record.jobId)
295
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, reason);
298
+ this.options.store?.fail(record.jobId, this.workerId, reason);
296
299
  const output = snapshot(record);
297
300
  this.notify({ type: "run_changed", run: output, previousStatus: "running", reason });
298
301
  cancelled.push(output);
@@ -311,7 +314,7 @@ export class PiboRunRegistry {
311
314
  this.finish(record);
312
315
  this.options.store?.updateRun(record.runId, record);
313
316
  if (record.jobId)
314
- this.options.store?.fail(record.jobId, `run-registry:${process.pid}`, reason);
317
+ this.options.store?.fail(record.jobId, this.workerId, reason);
315
318
  const output = snapshot(record);
316
319
  this.notify({ type: "run_changed", run: output, previousStatus: "running", reason });
317
320
  cancelled.push(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pasko70/pibo",
3
- "version": "1.11.1",
3
+ "version": "1.11.2",
4
4
  "type": "module",
5
5
  "imports": {
6
6
  "vscode": "./src/apps/chat-vscode/extension/src/vscode-shim.js"