@pasko70/pibo 1.11.1 → 1.11.3

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 (33) hide show
  1. package/dist/apps/chat/loop-api.js +22 -6
  2. package/dist/apps/chat-ui/assets/{dist-BBVpFHAq.js → dist-1w_WVrcu.js} +1 -1
  3. package/dist/apps/chat-ui/assets/{dist-DBnh8gXR.js → dist-BEd6jKzd.js} +1 -1
  4. package/dist/apps/chat-ui/assets/{dist-BXyVMdHv.js → dist-BI1eS8pb.js} +1 -1
  5. package/dist/apps/chat-ui/assets/{dist-CCGKu-Wj.js → dist-BLdgeEs8.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-CuGiEm5l.js → dist-BQmnOdXD.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-DeOnZ-pw.js → dist-BmGSbokp.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-CE0MvPLM.js → dist-Bwx_CaKF.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-BiGfVaXN.js → dist-CRYLB6HZ.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-DnQYnLQS.js → dist-CoUOMSbW.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-VT4x40uL.js → dist-Dehi8o5p.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-xtnVygdr.js → dist-o1kTkdhi.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{index-DNeE4HrG.css → index-CYLZe0Y0.css} +1 -1
  14. package/dist/apps/chat-ui/assets/{index-vcg8JNj9.js → index-DT80TM0S.js} +12 -12
  15. package/dist/apps/chat-ui/index.html +2 -2
  16. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  17. package/dist/apps/vscode-artifacts/{pibo-vscode-ext-1.11.1.vsix → pibo-vscode-ext-1.11.3.vsix} +0 -0
  18. package/dist/core/gateway-resource-guard.js +51 -5
  19. package/dist/core/routed-session.js +143 -24
  20. package/dist/core/runtime-telemetry.js +90 -0
  21. package/dist/core/runtime.js +1 -0
  22. package/dist/core/session-router.js +285 -62
  23. package/dist/debug/index.js +3 -1
  24. package/dist/gateway/server.js +2 -0
  25. package/dist/gateway/web.js +1 -0
  26. package/dist/loops/accounting.js +8 -1
  27. package/dist/loops/cli.js +1 -1
  28. package/dist/loops/service.js +167 -27
  29. package/dist/loops/store.js +229 -17
  30. package/dist/loops/tools.js +30 -9
  31. package/dist/reliability/store.js +19 -5
  32. package/dist/runs/registry.js +29 -7
  33. package/package.json +1 -1
@@ -16,13 +16,14 @@ 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";
23
23
  import { AsyncTelemetryWriter } from "../data/telemetry-writer.js";
24
24
  const DEFAULT_SUBAGENT_REPLY_TIMEOUT_MS = 10 * 60 * 1000;
25
25
  const DEFAULT_ROUTED_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
26
+ const DEFAULT_ROUTED_SESSION_DISPOSE_TIMEOUT_MS = 30 * 1000;
26
27
  export const LOOP_RUNTIME_RETRY_DEFAULTS = {
27
28
  enabled: true,
28
29
  maxRetries: 7,
@@ -115,7 +116,7 @@ function formatRunReminderMessage(notification) {
115
116
  ].join("\n");
116
117
  }
117
118
  function isRunReminderServiceMessage(event) {
118
- return event.source === "service" && event.text.startsWith("<pibo_run_notification>");
119
+ return event.source === "service" && event.capabilityScope === "run-reminder";
119
120
  }
120
121
  function isTerminalRunStatus(status) {
121
122
  return status === "completed" || status === "failed" || status === "timed_out" || status === "cancelled";
@@ -130,6 +131,16 @@ function piboRoomIdFromMetadata(metadata) {
130
131
  const value = metadata?.chatRoomId;
131
132
  return typeof value === "string" && value.length > 0 ? value : undefined;
132
133
  }
134
+ class PiboSessionDisposalTimeoutError extends Error {
135
+ piboSessionId;
136
+ timeoutMs;
137
+ constructor(piboSessionId, timeoutMs) {
138
+ super(`Timed out disposing Pibo session "${piboSessionId}" after ${timeoutMs}ms`);
139
+ this.piboSessionId = piboSessionId;
140
+ this.timeoutMs = timeoutMs;
141
+ this.name = "PiboSessionDisposalTimeoutError";
142
+ }
143
+ }
133
144
  function telemetryStoreFromSessionStore(store) {
134
145
  return store.getTelemetryStore?.();
135
146
  }
@@ -143,11 +154,16 @@ export class PiboSessionRouter {
143
154
  pendingSessions = new Map();
144
155
  listeners = new Set();
145
156
  runRegistry;
157
+ gatewayWorkAdmission = new GatewayWorkAdmissionController();
146
158
  signalRegistry;
147
159
  runtimeRegistry;
148
160
  scheduledRunReminders = new Map();
161
+ runReminderGenerations = new Map();
162
+ quiescingSessions = new Set();
163
+ disposingSessions = new Map();
149
164
  idleSessionTimers = new Map();
150
165
  routedSessionIdleTimeoutMs;
166
+ routedSessionDisposeTimeoutMs;
151
167
  baseProfile;
152
168
  pluginRegistry;
153
169
  sessionStore;
@@ -175,6 +191,10 @@ export class PiboSessionRouter {
175
191
  : typeof idleTimeoutMs === "number" && Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0
176
192
  ? idleTimeoutMs
177
193
  : DEFAULT_ROUTED_SESSION_IDLE_TIMEOUT_MS;
194
+ const disposeTimeoutMs = options.routedSessionDisposeTimeoutMs;
195
+ this.routedSessionDisposeTimeoutMs = typeof disposeTimeoutMs === "number" && Number.isFinite(disposeTimeoutMs) && disposeTimeoutMs > 0
196
+ ? disposeTimeoutMs
197
+ : DEFAULT_ROUTED_SESSION_DISPOSE_TIMEOUT_MS;
178
198
  const defaultProfileName = selectDefaultPiboProfileName(this.pluginRegistry);
179
199
  this.baseProfile = options.profile ?? createPiboProfileFromRegistryOrDefault(this.pluginRegistry, defaultProfileName);
180
200
  this.reliabilityStore = options.reliabilityStore ?? (options.persistSession === false ? undefined : createDefaultPiboReliabilityStore());
@@ -195,6 +215,16 @@ export class PiboSessionRouter {
195
215
  async emit(event) {
196
216
  if (this.closing)
197
217
  throw new Error("Pibo session router is disposed.");
218
+ const teardownAction = event.type === "execution" && (event.action === "dispose" || event.action === "kill" || event.action === "kill_all");
219
+ const teardownIds = teardownAction
220
+ ? [event.piboSessionId, ...this.descendantSessionIds(event.piboSessionId)]
221
+ : [];
222
+ if (event.type === "execution" && event.action === "abort") {
223
+ this.invalidateRunReminders([event.piboSessionId]);
224
+ }
225
+ else if (teardownAction) {
226
+ this.invalidateRunReminders(teardownIds);
227
+ }
198
228
  if (event.type === "message" && event.id) {
199
229
  const stored = this.sessionStore.get(event.piboSessionId);
200
230
  if (stored)
@@ -220,6 +250,9 @@ export class PiboSessionRouter {
220
250
  throw error;
221
251
  }
222
252
  this.clearIdleSessionTimer(event.piboSessionId);
253
+ let teardownCompleted = false;
254
+ if (teardownAction)
255
+ this.beginSessionQuiescence(teardownIds);
223
256
  try {
224
257
  if (event.type === "message") {
225
258
  return event.delivery === "steer"
@@ -232,12 +265,23 @@ export class PiboSessionRouter {
232
265
  else if (event.action === "dispose" || event.action === "kill" || event.action === "kill_all") {
233
266
  this.signalRegistry.project({ type: "session_disposed", piboSessionId: event.piboSessionId, reason: `${event.action} action` });
234
267
  }
235
- const output = await session.executeAction(event);
236
268
  if (event.action === "dispose") {
269
+ const output = {
270
+ type: "execution_result",
271
+ piboSessionId: event.piboSessionId,
272
+ eventId: event.id,
273
+ action: event.action,
274
+ result: { disposed: true },
275
+ };
276
+ this.emitOutput(output);
237
277
  await this.disposeSessionSubtree(event.piboSessionId, "dispose action", { cancelRuns: true });
278
+ teardownCompleted = true;
279
+ return output;
238
280
  }
239
- else if (event.action === "kill" || event.action === "kill_all") {
281
+ const output = await session.executeAction(event);
282
+ if (event.action === "kill" || event.action === "kill_all") {
240
283
  await this.disposeSessionSubtree(event.piboSessionId, `${event.action} action`, { cancelRuns: event.action === "kill_all" });
284
+ teardownCompleted = true;
241
285
  }
242
286
  else if (shouldResetSessionAfterAction(event.action)) {
243
287
  await this.resetCachedSession(event.piboSessionId, "provider auth changed");
@@ -245,6 +289,9 @@ export class PiboSessionRouter {
245
289
  return output;
246
290
  }
247
291
  catch (error) {
292
+ if (teardownAction && !teardownCompleted) {
293
+ await this.disposeSessionSubtree(event.piboSessionId, `${event.action} action failed`, { cancelRuns: event.action === "dispose" || event.action === "kill_all" }).catch(() => { });
294
+ }
248
295
  if (event.type === "message" && event.id) {
249
296
  this.signalRegistry.project({
250
297
  type: "message_rejected",
@@ -266,46 +313,118 @@ export class PiboSessionRouter {
266
313
  }
267
314
  }
268
315
  async killSession(piboSessionId, options) {
316
+ const rootSession = this.sessions.get(piboSessionId);
317
+ if (!rootSession)
318
+ return { killed: [], cancelledRuns: [] };
319
+ const ids = [piboSessionId, ...this.descendantSessionIds(piboSessionId)];
320
+ this.beginSessionQuiescence(ids);
269
321
  const killed = [];
270
322
  const cancelledRuns = [];
271
- const session = this.sessions.get(piboSessionId);
272
- if (session) {
273
- this.signalRegistry.project({ type: "session_disposed", piboSessionId, reason: "kill" });
274
- killed.push(await session.kill());
323
+ const failures = [];
324
+ for (const id of ids) {
325
+ const session = this.sessions.get(id);
326
+ if (session) {
327
+ this.signalRegistry.project({ type: "session_disposed", piboSessionId: id, reason: "kill" });
328
+ try {
329
+ killed.push(await session.kill());
330
+ }
331
+ catch (error) {
332
+ failures.push(error);
333
+ }
334
+ }
275
335
  if (options?.includeRuns) {
276
- const runs = this.runRegistry.cancelControllerRuns(piboSessionId);
277
- cancelledRuns.push(...runs.map((r) => r.runId));
336
+ const runs = this.runRegistry.cancelControllerRuns(id);
337
+ cancelledRuns.push(...runs.map((run) => run.runId));
278
338
  }
279
- const children = await this.killChildSessions(piboSessionId, options);
280
- killed.push(...children.killed);
281
- cancelledRuns.push(...children.cancelledRuns);
339
+ }
340
+ try {
282
341
  await this.disposeSessionSubtree(piboSessionId, "kill", { cancelRuns: false });
283
342
  }
343
+ catch (error) {
344
+ failures.push(error);
345
+ }
346
+ if (failures.length > 0)
347
+ throw new AggregateError(failures, `Failed to kill Pibo session subtree "${piboSessionId}"`);
284
348
  return { killed, cancelledRuns };
285
349
  }
350
+ async disposeRoutedSession(piboSessionId, session, reason) {
351
+ const disposal = Promise.resolve().then(() => session.dispose());
352
+ let timeout;
353
+ const timedOut = new Promise((_resolve, reject) => {
354
+ timeout = setTimeout(() => reject(new PiboSessionDisposalTimeoutError(piboSessionId, this.routedSessionDisposeTimeoutMs)), this.routedSessionDisposeTimeoutMs);
355
+ timeout.unref?.();
356
+ });
357
+ try {
358
+ await Promise.race([disposal, timedOut]);
359
+ }
360
+ catch (error) {
361
+ if (error instanceof PiboSessionDisposalTimeoutError) {
362
+ session.forceDispose(`${reason}; bounded disposal timeout`);
363
+ void disposal.catch(() => { });
364
+ }
365
+ throw error;
366
+ }
367
+ finally {
368
+ if (timeout)
369
+ clearTimeout(timeout);
370
+ }
371
+ }
286
372
  async disposeSessionSubtree(piboSessionId, reason, options) {
287
373
  const ids = [piboSessionId, ...this.descendantSessionIds(piboSessionId)];
288
- const sessions = [];
289
- for (const id of ids) {
290
- if (options.cancelRuns)
374
+ const existingDisposals = [...new Set(ids.map((id) => this.disposingSessions.get(id)).filter((value) => Boolean(value)))];
375
+ if (existingDisposals.length > 0)
376
+ await Promise.all(existingDisposals);
377
+ this.beginSessionQuiescence(ids);
378
+ if (options.cancelRuns) {
379
+ for (const id of ids)
291
380
  this.runRegistry.cancelControllerRuns(id);
292
- this.clearIdleSessionTimer(id);
293
- this.scheduledRunReminders.delete(id);
294
- const cached = this.sessions.get(id);
295
- if (cached)
296
- sessions.push(cached);
297
- this.sessions.delete(id);
298
381
  }
382
+ let releaseStart;
383
+ const startGate = new Promise((resolve) => {
384
+ releaseStart = resolve;
385
+ });
386
+ const operation = (async () => {
387
+ await startGate;
388
+ const pending = ids.map((id) => this.pendingSessions.get(id)).filter((value) => Boolean(value));
389
+ if (pending.length > 0)
390
+ await Promise.allSettled(pending);
391
+ const sessions = ids.flatMap((id) => {
392
+ const session = this.sessions.get(id);
393
+ return session ? [{ id, session }] : [];
394
+ });
395
+ const failures = [];
396
+ const closeResults = await Promise.allSettled(ids.map((id) => this.runtimeRegistry.closeControllerSessions(id, { force: true })));
397
+ for (const result of closeResults) {
398
+ if (result.status === "rejected")
399
+ failures.push(result.reason);
400
+ }
401
+ const disposeResults = await Promise.allSettled(sessions.map(({ id, session }) => this.disposeRoutedSession(id, session, reason)));
402
+ for (const result of disposeResults) {
403
+ if (result.status === "rejected")
404
+ failures.push(result.reason);
405
+ }
406
+ for (const { id, session } of sessions) {
407
+ if (this.sessions.get(id) === session)
408
+ this.sessions.delete(id);
409
+ }
410
+ if (failures.length > 0)
411
+ throw new AggregateError(failures, `Failed to dispose Pibo session subtree "${piboSessionId}"`);
412
+ })();
413
+ for (const id of ids)
414
+ this.disposingSessions.set(id, operation);
415
+ releaseStart?.();
299
416
  try {
300
- await Promise.all(ids.map((id) => this.runtimeRegistry.closeControllerSessions(id, { force: true })));
301
- await Promise.all(sessions.map((session) => session.dispose()));
417
+ await operation;
302
418
  }
303
419
  finally {
420
+ for (const id of ids) {
421
+ if (this.disposingSessions.get(id) === operation)
422
+ this.disposingSessions.delete(id);
423
+ this.quiescingSessions.delete(id);
424
+ this.signalRegistry.project({ type: "session_disposed", piboSessionId: id, reason });
425
+ }
304
426
  await this.telemetryWriter?.flush();
305
427
  }
306
- for (const id of ids) {
307
- this.signalRegistry.project({ type: "session_disposed", piboSessionId: id, reason });
308
- }
309
428
  }
310
429
  descendantSessionIds(parentId) {
311
430
  const output = [];
@@ -456,18 +575,28 @@ export class PiboSessionRouter {
456
575
  }
457
576
  async disposeAllUnsafe() {
458
577
  try {
578
+ const initialIds = [...new Set([...this.sessions.keys(), ...this.pendingSessions.keys()])];
579
+ this.beginSessionQuiescence(initialIds);
459
580
  await Promise.allSettled([...this.pendingSessions.values()]);
460
- const sessions = [...this.sessions.values()];
461
- this.sessions.clear();
581
+ const sessions = [...this.sessions.entries()];
462
582
  for (const timer of this.idleSessionTimers.values())
463
583
  clearTimeout(timer);
464
584
  this.idleSessionTimers.clear();
465
585
  this.runRegistry.cancelAll("Pibo session router was disposed.");
466
- for (const session of sessions)
467
- this.signalRegistry.project({ type: "session_disposed", piboSessionId: session.getStatus().piboSessionId, reason: "router disposed" });
468
586
  this.scheduledRunReminders.clear();
469
- await this.runtimeRegistry.closeAll({ force: true });
470
- await Promise.all(sessions.map((session) => session.dispose()));
587
+ const closeResult = await Promise.allSettled([this.runtimeRegistry.closeAll({ force: true })]);
588
+ const disposeResults = await Promise.allSettled(sessions.map(([id, session]) => this.disposeRoutedSession(id, session, "router disposed")));
589
+ for (const [id, session] of sessions) {
590
+ if (this.sessions.get(id) === session)
591
+ this.sessions.delete(id);
592
+ this.signalRegistry.project({ type: "session_disposed", piboSessionId: id, reason: "router disposed" });
593
+ }
594
+ const failures = [
595
+ ...closeResult.filter((result) => result.status === "rejected").map((result) => result.reason),
596
+ ...disposeResults.filter((result) => result.status === "rejected").map((result) => result.reason),
597
+ ];
598
+ if (failures.length > 0)
599
+ throw new AggregateError(failures, "Failed to dispose all Pibo sessions");
471
600
  }
472
601
  finally {
473
602
  await this.telemetryWriter?.dispose();
@@ -518,6 +647,14 @@ export class PiboSessionRouter {
518
647
  async getOrCreateSession(piboSessionId) {
519
648
  if (this.closing)
520
649
  throw new Error("Pibo session router is disposed.");
650
+ if (this.quiescingSessions.has(piboSessionId)) {
651
+ throw new Error(`Pibo session "${piboSessionId}" is quiescing.`);
652
+ }
653
+ const disposing = this.disposingSessions.get(piboSessionId);
654
+ if (disposing) {
655
+ await disposing;
656
+ return await this.getOrCreateSession(piboSessionId);
657
+ }
521
658
  const existing = this.sessions.get(piboSessionId);
522
659
  if (existing) {
523
660
  this.clearIdleSessionTimer(piboSessionId);
@@ -537,6 +674,7 @@ export class PiboSessionRouter {
537
674
  }
538
675
  async createRoutedSession(piboSessionId) {
539
676
  const piboSession = this.resolvePiboSession(piboSessionId);
677
+ let session;
540
678
  this.signalRegistry.project({ type: "session_created", session: piboSession });
541
679
  const profile = createPiboProfileFromRegistryOrDefault(this.pluginRegistry, piboSession.profile);
542
680
  const parentPiSessionId = piboSession.parentId
@@ -568,10 +706,11 @@ export class PiboSessionRouter {
568
706
  piboSessionId: piboSession.id,
569
707
  piboRoomId: piboRoomIdFromMetadata(piboSession.metadata),
570
708
  timezone: userSettings.timezone,
709
+ getActiveMessage: () => session?.getActiveMessage(),
571
710
  },
572
711
  });
573
712
  const initialFastMode = resolvePiboSessionInitialFastMode(piboSession) ?? selectRequestedFastMode(profileForSession(profile, piboSession.piSessionId, parentPiSessionId), modelDefaults) ?? false;
574
- const session = new RoutedSession(piboSession.id, runtime, this.emitOutput, this.pluginRegistry, this.options.forwardPiEvents ?? false, this.telemetryRecorder
713
+ session = new RoutedSession(piboSession.id, runtime, this.emitOutput, this.pluginRegistry, this.options.forwardPiEvents ?? false, this.telemetryRecorder
575
714
  ? (id, event, context) => this.telemetryRecorder?.recordPiEvent(id, event, { session: this.sessionStore.get(id), status: context.status, activeEventId: context.activeEventId })
576
715
  : undefined, initialFastMode, (result, event) => this.handleSessionOperation(result, event), (id, opts) => this.killChildSessions(id, opts), (state) => {
577
716
  this.signalRegistry.project({ type: "session_processing_changed", piboSessionId: piboSession.id, processing: state.processing, queuedMessages: state.queuedMessages });
@@ -582,7 +721,7 @@ export class PiboSessionRouter {
582
721
  }, (messages, reason) => this.telemetryRecorder?.recordMessagesInterrupted(messages, {
583
722
  session: this.sessionStore.get(piboSession.id),
584
723
  status: this.sessions.get(piboSession.id)?.getStatus(),
585
- }, reason));
724
+ }, reason), this.options.messagePreflight);
586
725
  this.sessions.set(piboSession.id, session);
587
726
  return session;
588
727
  }
@@ -640,14 +779,42 @@ export class PiboSessionRouter {
640
779
  });
641
780
  }
642
781
  async resetCachedSession(piboSessionId, reason) {
643
- const cached = this.sessions.get(piboSessionId);
782
+ const existingDisposal = this.disposingSessions.get(piboSessionId);
783
+ if (existingDisposal)
784
+ await existingDisposal;
644
785
  this.clearIdleSessionTimer(piboSessionId);
645
- this.sessions.delete(piboSessionId);
786
+ let releaseStart;
787
+ const startGate = new Promise((resolve) => {
788
+ releaseStart = resolve;
789
+ });
790
+ const operation = (async () => {
791
+ await startGate;
792
+ const pending = this.pendingSessions.get(piboSessionId);
793
+ if (pending)
794
+ await Promise.allSettled([pending]);
795
+ const cached = this.sessions.get(piboSessionId);
796
+ const failures = [];
797
+ const closeResult = await Promise.allSettled([this.runtimeRegistry.closeControllerSessions(piboSessionId, { force: true })]);
798
+ if (closeResult[0]?.status === "rejected")
799
+ failures.push(closeResult[0].reason);
800
+ if (cached) {
801
+ const disposeResult = await Promise.allSettled([this.disposeRoutedSession(piboSessionId, cached, reason ?? "session reset")]);
802
+ if (disposeResult[0]?.status === "rejected")
803
+ failures.push(disposeResult[0].reason);
804
+ if (this.sessions.get(piboSessionId) === cached)
805
+ this.sessions.delete(piboSessionId);
806
+ }
807
+ if (failures.length > 0)
808
+ throw new AggregateError(failures, `Failed to reset Pibo session "${piboSessionId}"`);
809
+ })();
810
+ this.disposingSessions.set(piboSessionId, operation);
811
+ releaseStart?.();
646
812
  try {
647
- await this.runtimeRegistry.closeControllerSessions(piboSessionId, { force: true });
648
- await cached?.dispose();
813
+ await operation;
649
814
  }
650
815
  finally {
816
+ if (this.disposingSessions.get(piboSessionId) === operation)
817
+ this.disposingSessions.delete(piboSessionId);
651
818
  await this.telemetryWriter?.flush();
652
819
  }
653
820
  if (reason)
@@ -697,23 +864,31 @@ export class PiboSessionRouter {
697
864
  createRunToolController(parentPiboSessionId) {
698
865
  return {
699
866
  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
- });
867
+ const admission = this.gatewayWorkAdmission.reserve(`yielded run ${toolName}`);
868
+ const reminderGeneration = this.runReminderGeneration(parentPiboSessionId);
869
+ let run;
870
+ try {
871
+ run = this.runRegistry.startToolRun({
872
+ controllerPiboSessionId: parentPiboSessionId,
873
+ toolName,
874
+ params,
875
+ completionPolicy,
876
+ retryable,
877
+ maxAttempts,
878
+ timeoutMs,
879
+ serviceWarning,
880
+ });
881
+ }
882
+ catch (error) {
883
+ admission.release();
884
+ throw error;
885
+ }
711
886
  void (async () => {
712
887
  try {
713
888
  const result = await execute();
714
889
  const completed = this.runRegistry.complete(run.runId, result);
715
890
  if (completed)
716
- this.scheduleRunReminder(parentPiboSessionId, false);
891
+ this.handleTerminalRunReminder(parentPiboSessionId, completed.runId, reminderGeneration);
717
892
  }
718
893
  catch (error) {
719
894
  const message = error instanceof Error ? error.message : String(error);
@@ -721,7 +896,10 @@ export class PiboSessionRouter {
721
896
  ? this.runRegistry.timeOut(run.runId, message, error.timeoutPhase)
722
897
  : this.runRegistry.fail(run.runId, message);
723
898
  if (terminalRun)
724
- this.scheduleRunReminder(parentPiboSessionId, false);
899
+ this.handleTerminalRunReminder(parentPiboSessionId, terminalRun.runId, reminderGeneration);
900
+ }
901
+ finally {
902
+ admission.release();
725
903
  }
726
904
  })();
727
905
  return run;
@@ -731,9 +909,8 @@ export class PiboSessionRouter {
731
909
  waitForRun: (runId, timeoutMs) => this.runRegistry.wait(parentPiboSessionId, runId, timeoutMs),
732
910
  readRun: (runId) => {
733
911
  const run = this.runRegistry.read(parentPiboSessionId, runId);
734
- if (run.consumed && isTerminalRunStatus(run.status)) {
912
+ if (run.consumed && isTerminalRunStatus(run.status))
735
913
  this.refreshQueuedRunReminders(parentPiboSessionId);
736
- }
737
914
  return run;
738
915
  },
739
916
  cancelRun: async (runId) => {
@@ -846,17 +1023,54 @@ export class PiboSessionRouter {
846
1023
  }
847
1024
  this.signalRegistry.project({ type: "run_changed", run: event.run, previousStatus: "previousStatus" in event ? event.previousStatus : undefined, reason: "reason" in event ? event.reason : event.type });
848
1025
  }
849
- scheduleRunReminder(piboSessionId, includeAlreadyNotified) {
1026
+ runReminderGeneration(piboSessionId) {
1027
+ return this.runReminderGenerations.get(piboSessionId) ?? 0;
1028
+ }
1029
+ invalidateRunReminders(piboSessionIds) {
1030
+ for (const piboSessionId of piboSessionIds) {
1031
+ this.runReminderGenerations.set(piboSessionId, this.runReminderGeneration(piboSessionId) + 1);
1032
+ this.scheduledRunReminders.delete(piboSessionId);
1033
+ try {
1034
+ this.sessions.get(piboSessionId)?.removeQueuedMessages(isRunReminderServiceMessage);
1035
+ }
1036
+ catch {
1037
+ // A concurrently disposed RoutedSession is already quiescent.
1038
+ }
1039
+ this.runRegistry.suppressControllerNotifications(piboSessionId);
1040
+ }
1041
+ }
1042
+ beginSessionQuiescence(piboSessionIds) {
1043
+ this.invalidateRunReminders(piboSessionIds);
1044
+ for (const piboSessionId of piboSessionIds) {
1045
+ this.quiescingSessions.add(piboSessionId);
1046
+ this.clearIdleSessionTimer(piboSessionId);
1047
+ }
1048
+ }
1049
+ handleTerminalRunReminder(piboSessionId, runId, generation) {
1050
+ if (generation !== this.runReminderGeneration(piboSessionId) || this.quiescingSessions.has(piboSessionId) || this.closing) {
1051
+ this.runRegistry.suppressNotification(piboSessionId, runId);
1052
+ return;
1053
+ }
1054
+ this.scheduleRunReminder(piboSessionId, false, generation);
1055
+ }
1056
+ scheduleRunReminder(piboSessionId, includeAlreadyNotified, expectedGeneration = this.runReminderGeneration(piboSessionId)) {
1057
+ if (this.closing || this.quiescingSessions.has(piboSessionId))
1058
+ return;
1059
+ if (expectedGeneration !== this.runReminderGeneration(piboSessionId))
1060
+ return;
850
1061
  if (!this.runRegistry.hasPendingNotification(piboSessionId, { includeAlreadyNotified }))
851
1062
  return;
852
1063
  const previous = this.scheduledRunReminders.get(piboSessionId);
853
- if (previous !== undefined) {
854
- this.scheduledRunReminders.set(piboSessionId, previous || includeAlreadyNotified);
1064
+ if (previous?.generation === expectedGeneration) {
1065
+ this.scheduledRunReminders.set(piboSessionId, {
1066
+ generation: expectedGeneration,
1067
+ includeAlreadyNotified: previous.includeAlreadyNotified || includeAlreadyNotified,
1068
+ });
855
1069
  return;
856
1070
  }
857
- this.scheduledRunReminders.set(piboSessionId, includeAlreadyNotified);
1071
+ this.scheduledRunReminders.set(piboSessionId, { generation: expectedGeneration, includeAlreadyNotified });
858
1072
  queueMicrotask(() => {
859
- void this.deliverRunReminder(piboSessionId);
1073
+ void this.deliverRunReminder(piboSessionId, expectedGeneration);
860
1074
  });
861
1075
  }
862
1076
  refreshQueuedRunReminders(piboSessionId) {
@@ -864,23 +1078,32 @@ export class PiboSessionRouter {
864
1078
  if (removed > 0)
865
1079
  this.scheduleRunReminder(piboSessionId, true);
866
1080
  }
867
- async deliverRunReminder(piboSessionId) {
868
- const includeAlreadyNotified = this.scheduledRunReminders.get(piboSessionId) ?? false;
1081
+ async deliverRunReminder(piboSessionId, expectedGeneration) {
1082
+ const scheduled = this.scheduledRunReminders.get(piboSessionId);
1083
+ if (!scheduled || scheduled.generation !== expectedGeneration)
1084
+ return;
869
1085
  this.scheduledRunReminders.delete(piboSessionId);
870
- const notification = this.runRegistry.createNotification(piboSessionId, { includeAlreadyNotified });
1086
+ if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
1087
+ return;
1088
+ const notification = this.runRegistry.createNotification(piboSessionId, { includeAlreadyNotified: scheduled.includeAlreadyNotified });
871
1089
  if (!notification)
872
1090
  return;
873
1091
  try {
874
1092
  const session = await this.getOrCreateSession(piboSessionId);
1093
+ if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
1094
+ return;
875
1095
  session.enqueueMessage({
876
1096
  type: "message",
877
1097
  piboSessionId,
878
1098
  text: formatRunReminderMessage(notification),
879
1099
  source: "service",
1100
+ capabilityScope: "run-reminder",
880
1101
  id: randomUUID(),
881
1102
  });
882
1103
  }
883
1104
  catch (error) {
1105
+ if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
1106
+ return;
884
1107
  const message = error instanceof Error ? error.message : String(error);
885
1108
  this.emitOutput({
886
1109
  type: "session_error",
@@ -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
@@ -1,6 +1,7 @@
1
1
  import { createServer } from "node:net";
2
2
  import { createDefaultPiboPluginRegistry, createPiboProfileFromRegistryOrDefault, resolvePiboProfileNameFromRegistryOrDefault } from "../plugins/builtin.js";
3
3
  import { PiboSessionRouter } from "../core/session-router.js";
4
+ import { createLoopMessagePreflight } from "../loops/store.js";
4
5
  import { loadPiboModelDefaults, selectRequestedModelProfile } from "../core/model-defaults.js";
5
6
  import { ResourceReaperService } from "../resources/reaper.js";
6
7
  import { DEFAULT_GATEWAY_HOST, DEFAULT_GATEWAY_PORT, encodeFrame, errorResponse, isGatewayRequestFrame, isGatewaySubscribeFrame, } from "./protocol.js";
@@ -147,6 +148,7 @@ export class PiboGatewayServer {
147
148
  persistSession: this.options.persistSession,
148
149
  pluginRegistry: this.pluginRegistry,
149
150
  sessionStore: this.sessionStore,
151
+ messagePreflight: createLoopMessagePreflight({ path: this.options.loopStorePath }),
150
152
  });
151
153
  this.unsubscribe = this.router.subscribe((event) => this.broadcastRouterEvent(event));
152
154
  this.server = createServer((socket) => this.handleSocket(socket));
@@ -197,6 +197,7 @@ export async function runWebGatewayServer(options = {}) {
197
197
  ...resolvedOptions,
198
198
  pluginRegistry,
199
199
  resourceReaper: resolveGatewayResourceReaperOptions(resolvedOptions),
200
+ loopStorePath: resolvedOptions.chat?.ralphStorePath,
200
201
  });
201
202
  await server.start();
202
203
  }
@@ -13,7 +13,14 @@ export function goalElapsedWallClockSeconds(job, now = new Date()) {
13
13
  export function goalRemainingTokens(job) {
14
14
  return job.tokenBudget === undefined ? undefined : Math.max(0, job.tokenBudget - (job.state.tokensUsed ?? 0));
15
15
  }
16
- export function goalCanStartNextTurn(job) {
16
+ export function goalCanStartNextTurn(job, now = new Date()) {
17
+ if (job.mode !== 'goal' || !job.enabled)
18
+ return false;
19
+ const status = job.state.goalStatus ?? 'paused';
20
+ if (status !== 'active')
21
+ return false;
22
+ if (job.state.nextAttemptAt && Date.parse(job.state.nextAttemptAt) > now.getTime())
23
+ return false;
17
24
  const remaining = goalRemainingTokens(job);
18
25
  return remaining === undefined || remaining > (job.tokenReserve ?? 0);
19
26
  }
package/dist/loops/cli.js CHANGED
@@ -207,7 +207,7 @@ export async function runLoopCli(argv = process.argv, defaults = {}) {
207
207
  printJson(job);
208
208
  else
209
209
  console.log(`${job.id}\tcancel-requested\t${job.name}`); store.close(); });
210
- program.command('remove').argument('<id>').description('Delete a Loop job').option('--json', 'Print JSON').action((id, options) => { const store = createDefaultPiboLoopStore({ path: program.opts().store }); const removed = store.removeJob(id); if (options.json)
210
+ program.command('remove').argument('<id>').description('Delete a Loop job and all of its runs and facts; active runs must be cancelled first').option('--json', 'Print JSON').action((id, options) => { const store = createDefaultPiboLoopStore({ path: program.opts().store }); const removed = store.removeJob(id); if (options.json)
211
211
  printJson({ removed });
212
212
  else
213
213
  console.log(removed ? 'removed' : 'not found'); store.close(); });