@pasko70/pibo 1.11.2 → 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 (30) 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.2.vsix → pibo-vscode-ext-1.11.3.vsix} +0 -0
  18. package/dist/core/routed-session.js +143 -24
  19. package/dist/core/runtime-telemetry.js +90 -0
  20. package/dist/core/runtime.js +1 -0
  21. package/dist/core/session-router.js +262 -50
  22. package/dist/gateway/server.js +2 -0
  23. package/dist/gateway/web.js +1 -0
  24. package/dist/loops/accounting.js +8 -1
  25. package/dist/loops/cli.js +1 -1
  26. package/dist/loops/service.js +167 -27
  27. package/dist/loops/store.js +229 -17
  28. package/dist/loops/tools.js +30 -9
  29. package/dist/runs/registry.js +19 -0
  30. package/package.json +1 -1
@@ -23,6 +23,7 @@ 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
  }
@@ -147,8 +158,12 @@ export class PiboSessionRouter {
147
158
  signalRegistry;
148
159
  runtimeRegistry;
149
160
  scheduledRunReminders = new Map();
161
+ runReminderGenerations = new Map();
162
+ quiescingSessions = new Set();
163
+ disposingSessions = new Map();
150
164
  idleSessionTimers = new Map();
151
165
  routedSessionIdleTimeoutMs;
166
+ routedSessionDisposeTimeoutMs;
152
167
  baseProfile;
153
168
  pluginRegistry;
154
169
  sessionStore;
@@ -176,6 +191,10 @@ export class PiboSessionRouter {
176
191
  : typeof idleTimeoutMs === "number" && Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0
177
192
  ? idleTimeoutMs
178
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;
179
198
  const defaultProfileName = selectDefaultPiboProfileName(this.pluginRegistry);
180
199
  this.baseProfile = options.profile ?? createPiboProfileFromRegistryOrDefault(this.pluginRegistry, defaultProfileName);
181
200
  this.reliabilityStore = options.reliabilityStore ?? (options.persistSession === false ? undefined : createDefaultPiboReliabilityStore());
@@ -196,6 +215,16 @@ export class PiboSessionRouter {
196
215
  async emit(event) {
197
216
  if (this.closing)
198
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
+ }
199
228
  if (event.type === "message" && event.id) {
200
229
  const stored = this.sessionStore.get(event.piboSessionId);
201
230
  if (stored)
@@ -221,6 +250,9 @@ export class PiboSessionRouter {
221
250
  throw error;
222
251
  }
223
252
  this.clearIdleSessionTimer(event.piboSessionId);
253
+ let teardownCompleted = false;
254
+ if (teardownAction)
255
+ this.beginSessionQuiescence(teardownIds);
224
256
  try {
225
257
  if (event.type === "message") {
226
258
  return event.delivery === "steer"
@@ -233,12 +265,23 @@ export class PiboSessionRouter {
233
265
  else if (event.action === "dispose" || event.action === "kill" || event.action === "kill_all") {
234
266
  this.signalRegistry.project({ type: "session_disposed", piboSessionId: event.piboSessionId, reason: `${event.action} action` });
235
267
  }
236
- const output = await session.executeAction(event);
237
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);
238
277
  await this.disposeSessionSubtree(event.piboSessionId, "dispose action", { cancelRuns: true });
278
+ teardownCompleted = true;
279
+ return output;
239
280
  }
240
- 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") {
241
283
  await this.disposeSessionSubtree(event.piboSessionId, `${event.action} action`, { cancelRuns: event.action === "kill_all" });
284
+ teardownCompleted = true;
242
285
  }
243
286
  else if (shouldResetSessionAfterAction(event.action)) {
244
287
  await this.resetCachedSession(event.piboSessionId, "provider auth changed");
@@ -246,6 +289,9 @@ export class PiboSessionRouter {
246
289
  return output;
247
290
  }
248
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
+ }
249
295
  if (event.type === "message" && event.id) {
250
296
  this.signalRegistry.project({
251
297
  type: "message_rejected",
@@ -267,46 +313,118 @@ export class PiboSessionRouter {
267
313
  }
268
314
  }
269
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);
270
321
  const killed = [];
271
322
  const cancelledRuns = [];
272
- const session = this.sessions.get(piboSessionId);
273
- if (session) {
274
- this.signalRegistry.project({ type: "session_disposed", piboSessionId, reason: "kill" });
275
- 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
+ }
276
335
  if (options?.includeRuns) {
277
- const runs = this.runRegistry.cancelControllerRuns(piboSessionId);
278
- cancelledRuns.push(...runs.map((r) => r.runId));
336
+ const runs = this.runRegistry.cancelControllerRuns(id);
337
+ cancelledRuns.push(...runs.map((run) => run.runId));
279
338
  }
280
- const children = await this.killChildSessions(piboSessionId, options);
281
- killed.push(...children.killed);
282
- cancelledRuns.push(...children.cancelledRuns);
339
+ }
340
+ try {
283
341
  await this.disposeSessionSubtree(piboSessionId, "kill", { cancelRuns: false });
284
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}"`);
285
348
  return { killed, cancelledRuns };
286
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
+ }
287
372
  async disposeSessionSubtree(piboSessionId, reason, options) {
288
373
  const ids = [piboSessionId, ...this.descendantSessionIds(piboSessionId)];
289
- const sessions = [];
290
- for (const id of ids) {
291
- 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)
292
380
  this.runRegistry.cancelControllerRuns(id);
293
- this.clearIdleSessionTimer(id);
294
- this.scheduledRunReminders.delete(id);
295
- const cached = this.sessions.get(id);
296
- if (cached)
297
- sessions.push(cached);
298
- this.sessions.delete(id);
299
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?.();
300
416
  try {
301
- await Promise.all(ids.map((id) => this.runtimeRegistry.closeControllerSessions(id, { force: true })));
302
- await Promise.all(sessions.map((session) => session.dispose()));
417
+ await operation;
303
418
  }
304
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
+ }
305
426
  await this.telemetryWriter?.flush();
306
427
  }
307
- for (const id of ids) {
308
- this.signalRegistry.project({ type: "session_disposed", piboSessionId: id, reason });
309
- }
310
428
  }
311
429
  descendantSessionIds(parentId) {
312
430
  const output = [];
@@ -457,18 +575,28 @@ export class PiboSessionRouter {
457
575
  }
458
576
  async disposeAllUnsafe() {
459
577
  try {
578
+ const initialIds = [...new Set([...this.sessions.keys(), ...this.pendingSessions.keys()])];
579
+ this.beginSessionQuiescence(initialIds);
460
580
  await Promise.allSettled([...this.pendingSessions.values()]);
461
- const sessions = [...this.sessions.values()];
462
- this.sessions.clear();
581
+ const sessions = [...this.sessions.entries()];
463
582
  for (const timer of this.idleSessionTimers.values())
464
583
  clearTimeout(timer);
465
584
  this.idleSessionTimers.clear();
466
585
  this.runRegistry.cancelAll("Pibo session router was disposed.");
467
- for (const session of sessions)
468
- this.signalRegistry.project({ type: "session_disposed", piboSessionId: session.getStatus().piboSessionId, reason: "router disposed" });
469
586
  this.scheduledRunReminders.clear();
470
- await this.runtimeRegistry.closeAll({ force: true });
471
- 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");
472
600
  }
473
601
  finally {
474
602
  await this.telemetryWriter?.dispose();
@@ -519,6 +647,14 @@ export class PiboSessionRouter {
519
647
  async getOrCreateSession(piboSessionId) {
520
648
  if (this.closing)
521
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
+ }
522
658
  const existing = this.sessions.get(piboSessionId);
523
659
  if (existing) {
524
660
  this.clearIdleSessionTimer(piboSessionId);
@@ -538,6 +674,7 @@ export class PiboSessionRouter {
538
674
  }
539
675
  async createRoutedSession(piboSessionId) {
540
676
  const piboSession = this.resolvePiboSession(piboSessionId);
677
+ let session;
541
678
  this.signalRegistry.project({ type: "session_created", session: piboSession });
542
679
  const profile = createPiboProfileFromRegistryOrDefault(this.pluginRegistry, piboSession.profile);
543
680
  const parentPiSessionId = piboSession.parentId
@@ -569,10 +706,11 @@ export class PiboSessionRouter {
569
706
  piboSessionId: piboSession.id,
570
707
  piboRoomId: piboRoomIdFromMetadata(piboSession.metadata),
571
708
  timezone: userSettings.timezone,
709
+ getActiveMessage: () => session?.getActiveMessage(),
572
710
  },
573
711
  });
574
712
  const initialFastMode = resolvePiboSessionInitialFastMode(piboSession) ?? selectRequestedFastMode(profileForSession(profile, piboSession.piSessionId, parentPiSessionId), modelDefaults) ?? false;
575
- 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
576
714
  ? (id, event, context) => this.telemetryRecorder?.recordPiEvent(id, event, { session: this.sessionStore.get(id), status: context.status, activeEventId: context.activeEventId })
577
715
  : undefined, initialFastMode, (result, event) => this.handleSessionOperation(result, event), (id, opts) => this.killChildSessions(id, opts), (state) => {
578
716
  this.signalRegistry.project({ type: "session_processing_changed", piboSessionId: piboSession.id, processing: state.processing, queuedMessages: state.queuedMessages });
@@ -583,7 +721,7 @@ export class PiboSessionRouter {
583
721
  }, (messages, reason) => this.telemetryRecorder?.recordMessagesInterrupted(messages, {
584
722
  session: this.sessionStore.get(piboSession.id),
585
723
  status: this.sessions.get(piboSession.id)?.getStatus(),
586
- }, reason));
724
+ }, reason), this.options.messagePreflight);
587
725
  this.sessions.set(piboSession.id, session);
588
726
  return session;
589
727
  }
@@ -641,14 +779,42 @@ export class PiboSessionRouter {
641
779
  });
642
780
  }
643
781
  async resetCachedSession(piboSessionId, reason) {
644
- const cached = this.sessions.get(piboSessionId);
782
+ const existingDisposal = this.disposingSessions.get(piboSessionId);
783
+ if (existingDisposal)
784
+ await existingDisposal;
645
785
  this.clearIdleSessionTimer(piboSessionId);
646
- 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?.();
647
812
  try {
648
- await this.runtimeRegistry.closeControllerSessions(piboSessionId, { force: true });
649
- await cached?.dispose();
813
+ await operation;
650
814
  }
651
815
  finally {
816
+ if (this.disposingSessions.get(piboSessionId) === operation)
817
+ this.disposingSessions.delete(piboSessionId);
652
818
  await this.telemetryWriter?.flush();
653
819
  }
654
820
  if (reason)
@@ -699,6 +865,7 @@ export class PiboSessionRouter {
699
865
  return {
700
866
  startToolRun: ({ toolName, params, completionPolicy, retryable, maxAttempts, timeoutMs, serviceWarning, execute }) => {
701
867
  const admission = this.gatewayWorkAdmission.reserve(`yielded run ${toolName}`);
868
+ const reminderGeneration = this.runReminderGeneration(parentPiboSessionId);
702
869
  let run;
703
870
  try {
704
871
  run = this.runRegistry.startToolRun({
@@ -721,7 +888,7 @@ export class PiboSessionRouter {
721
888
  const result = await execute();
722
889
  const completed = this.runRegistry.complete(run.runId, result);
723
890
  if (completed)
724
- this.scheduleRunReminder(parentPiboSessionId, false);
891
+ this.handleTerminalRunReminder(parentPiboSessionId, completed.runId, reminderGeneration);
725
892
  }
726
893
  catch (error) {
727
894
  const message = error instanceof Error ? error.message : String(error);
@@ -729,7 +896,7 @@ export class PiboSessionRouter {
729
896
  ? this.runRegistry.timeOut(run.runId, message, error.timeoutPhase)
730
897
  : this.runRegistry.fail(run.runId, message);
731
898
  if (terminalRun)
732
- this.scheduleRunReminder(parentPiboSessionId, false);
899
+ this.handleTerminalRunReminder(parentPiboSessionId, terminalRun.runId, reminderGeneration);
733
900
  }
734
901
  finally {
735
902
  admission.release();
@@ -742,9 +909,8 @@ export class PiboSessionRouter {
742
909
  waitForRun: (runId, timeoutMs) => this.runRegistry.wait(parentPiboSessionId, runId, timeoutMs),
743
910
  readRun: (runId) => {
744
911
  const run = this.runRegistry.read(parentPiboSessionId, runId);
745
- if (run.consumed && isTerminalRunStatus(run.status)) {
912
+ if (run.consumed && isTerminalRunStatus(run.status))
746
913
  this.refreshQueuedRunReminders(parentPiboSessionId);
747
- }
748
914
  return run;
749
915
  },
750
916
  cancelRun: async (runId) => {
@@ -857,17 +1023,54 @@ export class PiboSessionRouter {
857
1023
  }
858
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 });
859
1025
  }
860
- 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;
861
1061
  if (!this.runRegistry.hasPendingNotification(piboSessionId, { includeAlreadyNotified }))
862
1062
  return;
863
1063
  const previous = this.scheduledRunReminders.get(piboSessionId);
864
- if (previous !== undefined) {
865
- 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
+ });
866
1069
  return;
867
1070
  }
868
- this.scheduledRunReminders.set(piboSessionId, includeAlreadyNotified);
1071
+ this.scheduledRunReminders.set(piboSessionId, { generation: expectedGeneration, includeAlreadyNotified });
869
1072
  queueMicrotask(() => {
870
- void this.deliverRunReminder(piboSessionId);
1073
+ void this.deliverRunReminder(piboSessionId, expectedGeneration);
871
1074
  });
872
1075
  }
873
1076
  refreshQueuedRunReminders(piboSessionId) {
@@ -875,23 +1078,32 @@ export class PiboSessionRouter {
875
1078
  if (removed > 0)
876
1079
  this.scheduleRunReminder(piboSessionId, true);
877
1080
  }
878
- async deliverRunReminder(piboSessionId) {
879
- 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;
880
1085
  this.scheduledRunReminders.delete(piboSessionId);
881
- 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 });
882
1089
  if (!notification)
883
1090
  return;
884
1091
  try {
885
1092
  const session = await this.getOrCreateSession(piboSessionId);
1093
+ if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
1094
+ return;
886
1095
  session.enqueueMessage({
887
1096
  type: "message",
888
1097
  piboSessionId,
889
1098
  text: formatRunReminderMessage(notification),
890
1099
  source: "service",
1100
+ capabilityScope: "run-reminder",
891
1101
  id: randomUUID(),
892
1102
  });
893
1103
  }
894
1104
  catch (error) {
1105
+ if (this.closing || this.quiescingSessions.has(piboSessionId) || expectedGeneration !== this.runReminderGeneration(piboSessionId))
1106
+ return;
895
1107
  const message = error instanceof Error ? error.message : String(error);
896
1108
  this.emitOutput({
897
1109
  type: "session_error",
@@ -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(); });