@llblab/pi-actors 0.52.1 → 0.53.0

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.
@@ -10,6 +10,7 @@ import * as Observability from "./observability.ts";
10
10
  import * as Paths from "./paths.ts";
11
11
  import * as Pi from "./pi.ts";
12
12
  import * as RunDelivery from "./run-delivery.ts";
13
+ import * as RunDeliveryLineage from "./run-delivery-lineage.ts";
13
14
 
14
15
  export interface RunUiRuntime {
15
16
  close(): void;
@@ -43,12 +44,11 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
43
44
  let animationInterval: NodeJS.Timeout | undefined;
44
45
  let deliveryTimeout: NodeJS.Timeout | undefined;
45
46
  let notifyTimeout: NodeJS.Timeout | undefined;
46
- let recoverQueuedBatch = false;
47
47
  let recoverQueuedSteers = false;
48
48
  let running = false;
49
49
  let lastWatcherDiagnosticId = 0;
50
50
  const observation = Observability.createRunUiObservationState();
51
- const deliveryRecoveryDiagnostics = new Set<string>();
51
+ const deliveryObservation = Observability.createRunUiObservationState();
52
52
  const retirementAttempts = new Set<string>();
53
53
  const steerDiagnostics = new Set<string>();
54
54
 
@@ -70,9 +70,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
70
70
  notifyTimeout = undefined;
71
71
  if (deliveryTimeout) clearTimeout(deliveryTimeout);
72
72
  deliveryTimeout = undefined;
73
- recoverQueuedBatch = false;
74
73
  recoverQueuedSteers = false;
75
- deliveryRecoveryDiagnostics.clear();
76
74
  steerDiagnostics.clear();
77
75
  if (animationInterval) clearInterval(animationInterval);
78
76
  animationInterval = undefined;
@@ -228,12 +226,14 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
228
226
  };
229
227
  const admitCompletionTransitions = (
230
228
  ownerId: string,
231
- transitions: Observability.RunTransition[],
229
+ snapshot: Observability.RunUiSnapshot,
232
230
  ): boolean => {
233
231
  const existing = journal(ownerId).completion_batch;
234
232
  if (existing) return true;
235
- const members = Observability.collectRunCompletionBatchMembers(transitions)
236
- .slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
233
+ const members = Observability.collectRunCompletionBatchMembers(
234
+ snapshot.transitions,
235
+ snapshot.summary.runs,
236
+ ).slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
237
237
  if (members.length === 0) return false;
238
238
  RunDelivery.admitRunCompletionBatch({
239
239
  members,
@@ -244,6 +244,10 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
244
244
  };
245
245
  const isIdle = (ctx: Pi.ExtensionContext): boolean =>
246
246
  typeof ctx.isIdle !== "function" || ctx.isIdle();
247
+ const isDeliveryRoot = (ownerId: string): boolean => {
248
+ const inheritedOwner = RunDeliveryLineage.getProcessDeliveryOwnerId();
249
+ return inheritedOwner === undefined || inheritedOwner === ownerId;
250
+ };
247
251
  let flushCompletionBatch = (_ctx: Pi.ExtensionContext): boolean => false;
248
252
  const flushSteers = (ctx: Pi.ExtensionContext, ownerId: string): boolean => {
249
253
  const recovering = recoverQueuedSteers;
@@ -334,11 +338,25 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
334
338
  if (!notify) return false;
335
339
  const sink = Pi.createNotificationSink(deps.pi, ctx);
336
340
  retireCandidateRuns(ctx, snapshot.summary);
337
- const hasCompletionCandidates =
338
- Observability.collectRunCompletionBatchMembers(snapshot.transitions).length > 0;
339
- const hasCompletionBatch = Boolean(journal(ownerId).completion_batch);
341
+ const deliverySnapshot = isDeliveryRoot(ownerId)
342
+ ? Observability.readRunDeliverySnapshot(deliveryObservation, ownerId)
343
+ : undefined;
344
+ const hasCompletionCandidates = deliverySnapshot
345
+ ? Observability.collectRunCompletionBatchMembers(
346
+ deliverySnapshot.transitions,
347
+ deliverySnapshot.summary.runs,
348
+ ).length > 0
349
+ : false;
350
+ const hasCompletionBatch = isDeliveryRoot(ownerId) &&
351
+ Boolean(journal(ownerId).completion_batch);
340
352
  admitSteerEvents(ctx, ownerId, snapshot.attentionEvents);
341
353
  Observability.pruneRunUiObservationState(observation, snapshot);
354
+ if (deliverySnapshot) {
355
+ Observability.pruneRunUiObservationState(
356
+ deliveryObservation,
357
+ deliverySnapshot,
358
+ );
359
+ }
342
360
  if (!terminalOnly) {
343
361
  Observability.deliverRunAttentionNotifications(
344
362
  snapshot.attentionEvents.filter((event) =>
@@ -403,54 +421,48 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
403
421
  });
404
422
 
405
423
  flushCompletionBatch = (ctx: Pi.ExtensionContext): boolean => {
406
- if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) return false;
407
424
  const ownerId = activeOwnerId;
408
- if (!ownerId) return false;
425
+ if (!running || !ownerId) return false;
426
+ try {
427
+ if (Pi.getSessionId(ctx) !== ownerId) return false;
428
+ } catch {
429
+ return false;
430
+ }
409
431
  if (flushSteers(ctx, ownerId)) return true;
432
+ if (!isDeliveryRoot(ownerId)) return false;
410
433
  let batch = journal(ownerId).completion_batch;
411
434
  if (!batch) {
412
- const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
413
- admitCompletionTransitions(ownerId, snapshot.transitions);
414
- Observability.pruneRunUiObservationState(observation, snapshot);
435
+ const snapshot = Observability.readRunDeliverySnapshot(
436
+ deliveryObservation,
437
+ ownerId,
438
+ );
439
+ admitCompletionTransitions(ownerId, snapshot);
440
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
415
441
  batch = journal(ownerId).completion_batch;
416
442
  }
417
443
  if (!batch) return false;
418
444
  if (batch.phase === "presented") {
419
445
  finishPresentedBatch(ownerId, batch);
420
- const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
421
- admitCompletionTransitions(ownerId, snapshot.transitions);
422
- Observability.pruneRunUiObservationState(observation, snapshot);
446
+ const snapshot = Observability.readRunDeliverySnapshot(
447
+ deliveryObservation,
448
+ ownerId,
449
+ );
450
+ admitCompletionTransitions(ownerId, snapshot);
451
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
423
452
  batch = journal(ownerId).completion_batch;
424
453
  if (!batch) return false;
425
454
  }
426
455
  if (!isIdle(ctx)) return true;
427
456
  const content = RunDelivery.formatRunCompletionBatchMessage(batch);
428
457
  if (batch.phase === "queued") {
429
- if (!recoverQueuedBatch) return true;
430
- recoverQueuedBatch = false;
431
- const evidence = Pi.inspectRunCompletionBatchSessionEvidence(
432
- ctx,
433
- batch.batch_id,
434
- content,
435
- );
436
- if (evidence.status === "present") return true;
437
- if (evidence.status !== "absent") {
438
- const diagnosticKey = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
439
- if (!deliveryRecoveryDiagnostics.has(diagnosticKey)) {
440
- deliveryRecoveryDiagnostics.add(diagnosticKey);
441
- ctx.ui.notify(
442
- `Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains queued.`,
443
- "warning",
444
- );
445
- }
446
- return true;
447
- }
448
- if (!RunDelivery.resetRunCompletionBatchPending({
458
+ if (!RunDelivery.markRunCompletionBatchPresented({
449
459
  batchId: batch.batch_id,
450
460
  ownerId,
451
461
  tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
452
462
  })) return true;
453
- batch = journal(ownerId).completion_batch!;
463
+ const accepted = journal(ownerId).completion_batch;
464
+ if (accepted) finishPresentedBatch(ownerId, accepted);
465
+ return flushCompletionBatch(ctx);
454
466
  }
455
467
  if (!isIdle(ctx)) return true;
456
468
  try {
@@ -471,7 +483,19 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
471
483
  })) {
472
484
  throw new Error("Completion batch changed before queue acknowledgment");
473
485
  }
474
- recoverQueuedBatch = false;
486
+ if (!RunDelivery.markRunCompletionBatchPresented({
487
+ batchId: batch.batch_id,
488
+ ownerId,
489
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
490
+ })) {
491
+ throw new Error("Completion batch changed before durable acceptance");
492
+ }
493
+ const accepted = journal(ownerId).completion_batch;
494
+ if (accepted) finishPresentedBatch(ownerId, accepted);
495
+ ctx.ui.notify(
496
+ `Actor completions ready: ${batch.members.length}`,
497
+ "info",
498
+ );
475
499
  return true;
476
500
  };
477
501
 
@@ -479,11 +503,13 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
479
503
  close,
480
504
  flushCompletionBatch,
481
505
  projectContext(messages, ctx) {
482
- if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) {
506
+ const ownerId = activeOwnerId;
507
+ if (!running || !ownerId) return messages;
508
+ try {
509
+ if (Pi.getSessionId(ctx) !== ownerId) return messages;
510
+ } catch {
483
511
  return messages;
484
512
  }
485
- const ownerId = activeOwnerId;
486
- if (!ownerId) return messages;
487
513
  const steerContext = Pi.dedupeRunSteerContext(messages);
488
514
  let contextMessages = steerContext.messages;
489
515
  for (const steer of journal(ownerId).steers) {
@@ -560,7 +586,6 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
560
586
  close();
561
587
  activeContext = ctx;
562
588
  activeOwnerId = ownerId;
563
- recoverQueuedBatch = true;
564
589
  recoverQueuedSteers = true;
565
590
  running = true;
566
591
  try {
package/lib/runtime.ts CHANGED
@@ -325,6 +325,7 @@ export function createRecipeToolReloadWatcher(
325
325
  let rootWatcher: FSWatcher | undefined;
326
326
  let parentWatcher: FSWatcher | undefined;
327
327
  let failureNotified = false;
328
+ let notifyAfterReload = false;
328
329
  const setWatchStatus = (watchStatus: RecipeRegistryStatus["watch_status"]): void =>
329
330
  runtime.setWatchStatus?.(watchStatus);
330
331
  const close = (): void => {
@@ -336,6 +337,7 @@ export function createRecipeToolReloadWatcher(
336
337
  closingParent?.close();
337
338
  if (reloadTimeout) clearTimeout(reloadTimeout);
338
339
  reloadTimeout = undefined;
340
+ notifyAfterReload = false;
339
341
  setWatchStatus("closed");
340
342
  };
341
343
  const reportCallbackError = (error: unknown): void => {
@@ -358,14 +360,21 @@ export function createRecipeToolReloadWatcher(
358
360
  reportCallbackError(error);
359
361
  }
360
362
  };
361
- const scheduleReload = (ctx: RuntimeContext): void => {
363
+ const scheduleReload = (
364
+ ctx: RuntimeContext,
365
+ notifyActiveChange = false,
366
+ ): void => {
362
367
  failureNotified = false;
368
+ notifyAfterReload ||= notifyActiveChange;
363
369
  if (reloadTimeout) clearTimeout(reloadTimeout);
364
370
  reloadTimeout = setTimeout(() => {
365
371
  reloadTimeout = undefined;
366
372
  try {
367
373
  runtime.loadTools(ctx, deps.getResolutionContext?.());
368
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
374
+ if (notifyAfterReload) {
375
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
376
+ }
377
+ notifyAfterReload = false;
369
378
  } catch (error) {
370
379
  notifyFailure(ctx);
371
380
  reportCallbackError(error);
@@ -394,7 +403,7 @@ export function createRecipeToolReloadWatcher(
394
403
  parentWatcher = undefined;
395
404
  watcher.close();
396
405
  watchRoot(ctx, recipeRoot);
397
- scheduleReload(ctx);
406
+ scheduleReload(ctx, true);
398
407
  });
399
408
  parentWatcher = watcher;
400
409
  setWatchStatus("watching_parent");
@@ -415,16 +424,18 @@ export function createRecipeToolReloadWatcher(
415
424
  return;
416
425
  }
417
426
  try {
418
- const watcher = watchPath(recipeRoot, () => {
427
+ const watcher = watchPath(recipeRoot, (_event, changedFile) => {
419
428
  if (rootWatcher !== watcher) return;
420
429
  if (!pathExists(recipeRoot)) {
421
430
  rootWatcher = undefined;
422
431
  watcher.close();
423
- scheduleReload(ctx);
432
+ scheduleReload(ctx, true);
424
433
  watchParent(ctx, recipeRoot);
425
434
  return;
426
435
  }
427
- scheduleReload(ctx);
436
+ const relativeChange = changedFile ? String(changedFile) : "";
437
+ const firstSegment = relativeChange.split(/[\\/]/u)[0];
438
+ scheduleReload(ctx, Boolean(relativeChange) && firstSegment !== "drafts");
428
439
  });
429
440
  rootWatcher = watcher;
430
441
  setWatchStatus("watching_root");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -107,7 +107,7 @@ Run = Recipe + Trace + Control
107
107
  ```
108
108
 
109
109
  1. Spawn with the exact logical Recipe identity and caller-owned values.
110
- 2. Retain the returned `run:<id>` and normally wait for its settled completion batch instead of polling.
110
+ 2. Retain the returned `run:<id>` and normally wait for the root coordinator's settled completion batch instead of polling. Nested actor completions accumulate under their containing top-level Run and arrive in that one tree-compressed batch; descendant sessions do not need notification options.
111
111
  3. Inspect `view=trace` when retained observations or attention matter.
112
112
  4. Inspect `view=control` before diagnosing service readiness, stale work, or saturation.
113
113
  5. Send `message` only for an action declared and consumed by that controlled Recipe.