@llblab/pi-actors 0.49.1 → 0.49.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.
package/BACKLOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Project Backlog
2
2
 
3
- - [ ] `Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority.
3
+ - [ ] `0.50.0 hardening`: Close the confirmed authorization, bounded-inspection, and operator-truth gaps from the v0.49.1 production-readiness review.
4
+ - [ ] `Run boundary checkpoint`: Public Run operations remain owner-safe and bounded under missing identity and adversarial evidence sizes.
5
+ - [ ] Make Run-specific `inspect` and `message` operations fail closed when the caller session identity is unavailable, keep runtime inventory owner-filtered, and add missing-context plus cross-owner regressions.
6
+ - [ ] Bound session-evidence and artifact-manifest inspection before full-file materialization; use capped/streaming reads or hashing as appropriate and add adversarial-size regressions.
7
+ - [ ] `Operator truth checkpoint`: Public status and recovery guidance exactly match supported runtime behavior.
8
+ - [ ] Route automatic-review status through the canonical policy parser so `0`, `false`, and `off` are reported consistently without case sensitivity.
9
+ - [ ] Replace the removed `session:<id>` and `session:all` recovery hints with supported inspection guidance.
10
+ - [ ] Document exact `message target=run:<id>` examples for runtime-owned `kill`, `archive`, and `prune`, including their state and artifact-preservation constraints.
11
+ - [ ] Pass focused boundary and operator-contract regressions plus full package validation before release.
12
+
13
+ - [ ] `Future minor — Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority; keep this feature outside the 0.49.2 and 0.50.0 cohorts.
4
14
  - [ ] Publish `PlaybackStatus`, track metadata, duration, read-time position, volume, and supported capabilities under one stable session-scoped bus identity; disappear cleanly when the Run stops or its generation is replaced, and fail soft when the user D-Bus session is unavailable.
5
15
  - [ ] Map `Play`, `Pause`, `PlayPause`, `Next`, `Previous`, `Stop`, `Seek`, `SetPosition`, and `Volume` back into the existing generation-fenced music-player Control/helper contract rather than signaling the backend or editing Run state directly.
6
16
  - [ ] Validate deterministic D-Bus contract behavior plus a live GNOME smoke showing the media surface, metadata, progress, volume, and controls while preserving backend independence and exact Actor ownership.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.49.2: Stale-Context Lifecycle Hotfix
8
+
9
+ - `Stale-Context Lifecycle Hotfix`: Contains Run UI animation, delayed watcher, reconciliation, error-handler, retirement, and Recipe-reload callbacks so invalidated Pi contexts cannot escape into the host event loop. Session owners are captured while live, stale shutdown cannot close a replacement session, and parent teardown plus shutdown notification remain identity-fenced and no-throw (GitHub issue #125).
10
+
7
11
  ## 0.49.1: Maintained Telegram View Routing
8
12
 
9
13
  - `Maintained Telegram View Routing`: Music Player now treats Telegram-originated control intent as a breadcrumb to its ready capability-owned Generative App, preferring bind/invoke over one-shot prompt buttons while preserving Actor playback authority and an explicit model-mediated fallback when the app runtime is unavailable.
@@ -17,6 +17,8 @@ import * as Tools from "./tools.js";
17
17
  import * as ToolsResponse from "./tools-response.js";
18
18
  export function createActorExtensionRuntime(pi) {
19
19
  let activeRunContext;
20
+ let activeRunOwnerId;
21
+ const runOwnerIdsByContext = new WeakMap();
20
22
  const recipeResolutionContextsBySession = new Map();
21
23
  const getRunOwnerId = Pi.getSessionId;
22
24
  const getRecipeResolutionContext = (ctx) => {
@@ -32,9 +34,21 @@ export function createActorExtensionRuntime(pi) {
32
34
  getRunOwnerId,
33
35
  getThinkingLevel: () => pi.getThinkingLevel(),
34
36
  });
35
- const runUiRuntime = RunUiRuntime.createRunUiRuntime({
37
+ let recipeReload;
38
+ let runUiRuntime;
39
+ const closeActiveSessionRuntimes = () => {
40
+ const ownerId = activeRunOwnerId;
41
+ activeRunContext = undefined;
42
+ activeRunOwnerId = undefined;
43
+ runUiRuntime?.close();
44
+ automaticReview.close();
45
+ recipeReload?.close();
46
+ if (ownerId)
47
+ recipeResolutionContextsBySession.delete(ownerId);
48
+ };
49
+ runUiRuntime = RunUiRuntime.createRunUiRuntime({
36
50
  getActiveContext: () => activeRunContext,
37
- getRunOwnerId,
51
+ onCallbackError: closeActiveSessionRuntimes,
38
52
  onRunEvent: automaticReview.schedule,
39
53
  pi,
40
54
  });
@@ -77,10 +91,11 @@ export function createActorExtensionRuntime(pi) {
77
91
  reservedToolNames: Tools.RESERVED_TOOL_NAMES,
78
92
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
79
93
  });
80
- const recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
94
+ recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
81
95
  getResolutionContext: () => activeRunContext
82
96
  ? getRecipeResolutionContext(activeRunContext)
83
97
  : undefined,
98
+ onCallbackError: closeActiveSessionRuntimes,
84
99
  });
85
100
  return {
86
101
  beforeAgentStart(systemPrompt, skills, ctx) {
@@ -102,25 +117,29 @@ export function createActorExtensionRuntime(pi) {
102
117
  automaticReview.schedule();
103
118
  },
104
119
  onSessionShutdown(reason, ctx) {
105
- recipeResolutionContextsBySession.delete(getRunOwnerId(ctx));
106
- activeRunContext = undefined;
107
- automaticReview.close();
108
- recipeReload.close();
109
- runUiRuntime.shutdown(reason, ctx);
120
+ const ownerId = runOwnerIdsByContext.get(ctx);
121
+ runOwnerIdsByContext.delete(ctx);
122
+ if (activeRunContext === ctx)
123
+ closeActiveSessionRuntimes();
124
+ if (ownerId)
125
+ recipeResolutionContextsBySession.delete(ownerId);
126
+ runUiRuntime.shutdown(reason, ownerId, ctx);
110
127
  },
111
128
  async onSessionStart(ctx) {
112
129
  const sessionId = getRunOwnerId(ctx);
113
130
  recipeResolutionContextsBySession.set(sessionId, RecipeResolution.createEmptyRecipeResolutionContext(sessionId, ctx.cwd));
114
131
  ctx.ui.setWidget("zz-pi-actors-comms", undefined);
115
132
  activeRunContext = ctx;
133
+ activeRunOwnerId = sessionId;
134
+ runOwnerIdsByContext.set(ctx, sessionId);
116
135
  runUiRuntime.close();
117
136
  automaticReview.close();
118
137
  recipeReload.close();
119
138
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
120
- if (activeRunContext !== ctx)
139
+ if (activeRunContext !== ctx || activeRunOwnerId !== sessionId)
121
140
  return;
122
141
  automaticReview.start(ctx);
123
- runUiRuntime.start(ctx);
142
+ runUiRuntime.start(ctx, sessionId);
124
143
  recipeReload.watch(ctx);
125
144
  },
126
145
  registerCoreTools() {
@@ -229,7 +229,12 @@ export function createRunTerminalReconciliationLoop(input) {
229
229
  input.reconcile();
230
230
  }
231
231
  catch (error) {
232
- input.onError?.(error);
232
+ try {
233
+ input.onError?.(error);
234
+ }
235
+ catch {
236
+ /* reconciliation callbacks must never escape into the host event loop */
237
+ }
233
238
  }
234
239
  };
235
240
  const close = () => {
@@ -3,16 +3,23 @@
3
3
  * Zones: run watcher lifecycle, terminal reconciliation, status animation, shutdown teardown
4
4
  * Owns event-driven run UI coordination without owning actor execution semantics.
5
5
  */
6
+ import * as AsyncRuns from "./async-runs.ts";
7
+ import * as Observability from "./observability.ts";
6
8
  import * as Pi from "./pi.ts";
7
9
  export interface RunUiRuntime {
8
10
  close(): void;
9
- shutdown(eventReason: string, ctx: Pi.ExtensionContext): void;
10
- start(ctx: Pi.ExtensionContext): void;
11
+ shutdown(eventReason: string, ownerId: string | undefined, ctx?: Pi.ExtensionContext): void;
12
+ start(ctx: Pi.ExtensionContext, ownerId: string): void;
11
13
  }
12
14
  export interface RunUiRuntimeDeps {
15
+ animationIntervalMs?: number;
16
+ createRunStateWatcher?: typeof Observability.createRunStateWatcher;
17
+ createRunTerminalReconciliationLoop?: typeof Observability.createRunTerminalReconciliationLoop;
13
18
  getActiveContext(): Pi.ExtensionContext | undefined;
14
- getRunOwnerId(ctx: Pi.ExtensionContext): string;
19
+ notificationDelayMs?: number;
20
+ onCallbackError?: (error: unknown) => void;
15
21
  onRunEvent(): void;
16
22
  pi: Pi.ExtensionAPI;
23
+ teardownRunsOwnedByParent?: typeof AsyncRuns.teardownRunsOwnedByParent;
17
24
  }
18
25
  export declare function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime;
@@ -8,22 +8,78 @@ import * as Observability from "./observability.js";
8
8
  import * as Paths from "./paths.js";
9
9
  import * as Pi from "./pi.js";
10
10
  export function createRunUiRuntime(deps) {
11
+ let activeContext;
12
+ let activeOwnerId;
11
13
  let animationInterval;
12
14
  let notifyTimeout;
15
+ let running = false;
13
16
  let lastWatcherDiagnosticId = 0;
14
17
  const observation = Observability.createRunUiObservationState();
15
18
  const retirementAttempts = new Set();
16
19
  const terminalNotificationsInFlight = new Set();
20
+ const close = () => {
21
+ running = false;
22
+ activeContext = undefined;
23
+ activeOwnerId = undefined;
24
+ try {
25
+ watcher.close();
26
+ }
27
+ catch {
28
+ /* cleanup must not escape a host callback */
29
+ }
30
+ try {
31
+ reconciliation.close();
32
+ }
33
+ catch {
34
+ /* cleanup must not escape a host callback */
35
+ }
36
+ if (notifyTimeout)
37
+ clearTimeout(notifyTimeout);
38
+ notifyTimeout = undefined;
39
+ if (animationInterval)
40
+ clearInterval(animationInterval);
41
+ animationInterval = undefined;
42
+ };
43
+ const stopAfterCallbackFailure = (label, error, expectedContext) => {
44
+ if (activeContext !== expectedContext)
45
+ return;
46
+ close();
47
+ try {
48
+ deps.onCallbackError?.(error);
49
+ }
50
+ catch {
51
+ /* host callback containment must remain no-throw */
52
+ }
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ try {
55
+ expectedContext.ui.notify(`Actor ${label} failed: ${message}`, "error");
56
+ }
57
+ catch {
58
+ /* stale context or unavailable UI */
59
+ }
60
+ };
61
+ const runActiveCallback = (label, callback) => {
62
+ if (!running || !activeContext || !activeOwnerId)
63
+ return;
64
+ const ctx = activeContext;
65
+ try {
66
+ if (deps.getActiveContext() !== ctx)
67
+ return;
68
+ callback(ctx, activeOwnerId);
69
+ }
70
+ catch (error) {
71
+ stopAfterCallbackFailure(label, error, ctx);
72
+ }
73
+ };
17
74
  const retireCandidateRuns = (ctx, summary) => {
18
75
  void Observability.executeRunRetirements(summary, {
19
76
  attempted: retirementAttempts,
20
77
  cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
21
78
  notify: (message, level) => ctx.ui.notify(message, level),
22
79
  sendStop: async (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
23
- });
80
+ }).catch((error) => stopAfterCallbackFailure("Run retirement callback", error, ctx));
24
81
  };
25
- const update = (ctx, notify = false, terminalOnly = false) => {
26
- const ownerId = deps.getRunOwnerId(ctx);
82
+ const update = (ctx, ownerId, notify = false, terminalOnly = false) => {
27
83
  const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
28
84
  ctx.ui.setStatus("zz-pi-actors-runs", snapshot.status ? ctx.ui.theme.fg("dim", snapshot.status) : undefined);
29
85
  if (!notify)
@@ -45,62 +101,55 @@ export function createRunUiRuntime(deps) {
45
101
  }
46
102
  };
47
103
  const scheduleUpdate = () => {
104
+ if (!running)
105
+ return;
48
106
  if (notifyTimeout)
49
107
  clearTimeout(notifyTimeout);
50
108
  notifyTimeout = setTimeout(() => {
51
- const ctx = deps.getActiveContext();
52
- if (!ctx)
53
- return;
54
- watcher.refresh();
55
- update(ctx, true);
56
- deps.onRunEvent();
57
- reportDiagnostics(ctx);
58
- }, 50);
109
+ runActiveCallback("Run watcher callback", (ctx, ownerId) => {
110
+ watcher.refresh();
111
+ update(ctx, ownerId, true);
112
+ deps.onRunEvent();
113
+ reportDiagnostics(ctx);
114
+ });
115
+ }, deps.notificationDelayMs ?? 50);
59
116
  notifyTimeout.unref?.();
60
117
  };
61
- const watcher = Observability.createRunStateWatcher({
118
+ const watcher = (deps.createRunStateWatcher ?? Observability.createRunStateWatcher)({
62
119
  stateRoot: Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
63
120
  onChange: scheduleUpdate,
64
121
  });
65
- const reconciliation = Observability.createRunTerminalReconciliationLoop({
122
+ const reconciliation = (deps.createRunTerminalReconciliationLoop ??
123
+ Observability.createRunTerminalReconciliationLoop)({
66
124
  onError: (error) => {
67
- const ctx = deps.getActiveContext();
68
- if (!ctx)
125
+ if (!running || !activeContext)
69
126
  return;
70
- const message = error instanceof Error ? error.message : String(error);
71
- ctx.ui.notify(`Actor terminal reconciliation failed: ${message}`, "error");
127
+ stopAfterCallbackFailure("terminal reconciliation callback", error, activeContext);
72
128
  },
73
129
  reconcile: () => {
74
- const ctx = deps.getActiveContext();
75
- if (!ctx)
76
- return;
77
- Observability.reconcileRunTerminalNotifications({
78
- inFlight: terminalNotificationsInFlight,
79
- ownerId: deps.getRunOwnerId(ctx),
80
- sink: Pi.createNotificationSink(deps.pi, ctx),
81
- state: observation,
82
- includeAttention: true,
130
+ runActiveCallback("terminal reconciliation callback", (ctx, ownerId) => {
131
+ Observability.reconcileRunTerminalNotifications({
132
+ inFlight: terminalNotificationsInFlight,
133
+ ownerId,
134
+ sink: Pi.createNotificationSink(deps.pi, ctx),
135
+ state: observation,
136
+ includeAttention: true,
137
+ });
138
+ reportDiagnostics(ctx);
83
139
  });
84
- reportDiagnostics(ctx);
85
140
  },
86
- refreshWatcher: () => watcher.refresh(),
141
+ refreshWatcher: () => {
142
+ if (running)
143
+ watcher.refresh();
144
+ },
87
145
  });
88
- const close = () => {
89
- watcher.close();
90
- reconciliation.close();
91
- if (notifyTimeout)
92
- clearTimeout(notifyTimeout);
93
- notifyTimeout = undefined;
94
- if (animationInterval)
95
- clearInterval(animationInterval);
96
- animationInterval = undefined;
97
- };
98
146
  return {
99
147
  close,
100
- shutdown(eventReason, ctx) {
101
- close();
102
- const teardown = AsyncRuns.teardownRunsOwnedByParent(deps.getRunOwnerId(ctx), Paths.EXTENSION_RUNTIME_PATHS.runStateRoot, { trigger: `session_shutdown:${eventReason}` });
103
- if (teardown.failed === 0)
148
+ shutdown(eventReason, ownerId, ctx) {
149
+ if (!ownerId)
150
+ return;
151
+ const teardown = (deps.teardownRunsOwnedByParent ?? AsyncRuns.teardownRunsOwnedByParent)(ownerId, Paths.EXTENSION_RUNTIME_PATHS.runStateRoot, { trigger: `session_shutdown:${eventReason}` });
152
+ if (teardown.failed === 0 || !ctx)
104
153
  return;
105
154
  try {
106
155
  ctx.ui.notify(`Actor shutdown teardown: killed=${teardown.killed} failed=${teardown.failed} skipped=${teardown.skipped} discovery_failed=${teardown.discoveryFailed}. Summary: ${teardown.summaryPath ?? "unavailable"}.`, "warning");
@@ -109,17 +158,25 @@ export function createRunUiRuntime(deps) {
109
158
  /* stale shutdown context */
110
159
  }
111
160
  },
112
- start(ctx) {
161
+ start(ctx, ownerId) {
113
162
  close();
114
- Observability.primeRunAttentionState(observation, deps.getRunOwnerId(ctx));
115
- update(ctx, true, true);
116
- watcher.refresh();
117
- reconciliation.start();
118
- animationInterval = setInterval(() => {
119
- if (deps.getActiveContext() === ctx)
120
- update(ctx);
121
- }, 1000);
122
- animationInterval.unref?.();
163
+ activeContext = ctx;
164
+ activeOwnerId = ownerId;
165
+ running = true;
166
+ try {
167
+ Observability.primeRunAttentionState(observation, ownerId);
168
+ update(ctx, ownerId, true, true);
169
+ watcher.refresh();
170
+ reconciliation.start();
171
+ animationInterval = setInterval(() => {
172
+ runActiveCallback("status animation callback", (current, currentOwnerId) => update(current, currentOwnerId));
173
+ }, deps.animationIntervalMs ?? 1000);
174
+ animationInterval.unref?.();
175
+ }
176
+ catch (error) {
177
+ close();
178
+ throw error;
179
+ }
123
180
  },
124
181
  };
125
182
  }
@@ -63,7 +63,9 @@ export declare function createAutoToolsRuntime(deps: ToolRegistryRuntimeDeps): T
63
63
  export interface RecipeToolReloadWatcherDeps {
64
64
  exists?: (path: string) => boolean;
65
65
  getResolutionContext?: () => RecipeResolutionContext | undefined;
66
+ onCallbackError?: (error: unknown) => void;
66
67
  recipeRoot?: string;
68
+ reloadDelayMs?: number;
67
69
  watchPath?: typeof watch;
68
70
  }
69
71
  export declare function createRecipeToolReloadWatcher(runtime: Pick<ToolRegistryRuntime, "loadTools"> & Partial<Pick<ToolRegistryRuntime, "setWatchStatus">>, deps?: RecipeToolReloadWatcherDeps): RecipeToolReloadWatcher;
@@ -236,21 +236,41 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
236
236
  reloadTimeout = undefined;
237
237
  setWatchStatus("closed");
238
238
  };
239
+ const reportCallbackError = (error) => {
240
+ try {
241
+ deps.onCallbackError?.(error);
242
+ }
243
+ catch {
244
+ /* host callback containment must remain no-throw */
245
+ }
246
+ };
239
247
  const notifyFailure = (ctx) => {
240
248
  if (failureNotified)
241
249
  return;
242
250
  failureNotified = true;
243
251
  setWatchStatus("failed");
244
- ctx.ui.notify("Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.", "warning");
252
+ try {
253
+ ctx.ui.notify("Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.", "warning");
254
+ }
255
+ catch (error) {
256
+ reportCallbackError(error);
257
+ }
245
258
  };
246
259
  const scheduleReload = (ctx) => {
247
260
  failureNotified = false;
248
261
  if (reloadTimeout)
249
262
  clearTimeout(reloadTimeout);
250
263
  reloadTimeout = setTimeout(() => {
251
- runtime.loadTools(ctx, deps.getResolutionContext?.());
252
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
253
- }, 150);
264
+ reloadTimeout = undefined;
265
+ try {
266
+ runtime.loadTools(ctx, deps.getResolutionContext?.());
267
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
268
+ }
269
+ catch (error) {
270
+ notifyFailure(ctx);
271
+ reportCallbackError(error);
272
+ }
273
+ }, deps.reloadDelayMs ?? 150);
254
274
  reloadTimeout.unref?.();
255
275
  };
256
276
  const watchParent = (ctx, recipeRoot) => {
@@ -35,6 +35,8 @@ export function createActorExtensionRuntime(
35
35
  pi: Pi.ExtensionAPI,
36
36
  ): ActorExtensionRuntime {
37
37
  let activeRunContext: Pi.ExtensionContext | undefined;
38
+ let activeRunOwnerId: string | undefined;
39
+ const runOwnerIdsByContext = new WeakMap<Pi.ExtensionContext, string>();
38
40
  const recipeResolutionContextsBySession = new Map<
39
41
  string,
40
42
  RecipeResolution.RecipeResolutionContext
@@ -55,9 +57,20 @@ export function createActorExtensionRuntime(
55
57
  getRunOwnerId,
56
58
  getThinkingLevel: () => pi.getThinkingLevel(),
57
59
  });
58
- const runUiRuntime = RunUiRuntime.createRunUiRuntime({
60
+ let recipeReload: Runtime.RecipeToolReloadWatcher | undefined;
61
+ let runUiRuntime: RunUiRuntime.RunUiRuntime;
62
+ const closeActiveSessionRuntimes = (): void => {
63
+ const ownerId = activeRunOwnerId;
64
+ activeRunContext = undefined;
65
+ activeRunOwnerId = undefined;
66
+ runUiRuntime?.close();
67
+ automaticReview.close();
68
+ recipeReload?.close();
69
+ if (ownerId) recipeResolutionContextsBySession.delete(ownerId);
70
+ };
71
+ runUiRuntime = RunUiRuntime.createRunUiRuntime({
59
72
  getActiveContext: () => activeRunContext,
60
- getRunOwnerId,
73
+ onCallbackError: closeActiveSessionRuntimes,
61
74
  onRunEvent: automaticReview.schedule,
62
75
  pi,
63
76
  });
@@ -102,11 +115,12 @@ export function createActorExtensionRuntime(
102
115
  reservedToolNames: Tools.RESERVED_TOOL_NAMES,
103
116
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
104
117
  });
105
- const recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
118
+ recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
106
119
  getResolutionContext: () =>
107
120
  activeRunContext
108
121
  ? getRecipeResolutionContext(activeRunContext)
109
122
  : undefined,
123
+ onCallbackError: closeActiveSessionRuntimes,
110
124
  });
111
125
  return {
112
126
  beforeAgentStart(systemPrompt, skills, ctx) {
@@ -131,11 +145,11 @@ export function createActorExtensionRuntime(
131
145
  if (activeRunContext === ctx) automaticReview.schedule();
132
146
  },
133
147
  onSessionShutdown(reason, ctx) {
134
- recipeResolutionContextsBySession.delete(getRunOwnerId(ctx));
135
- activeRunContext = undefined;
136
- automaticReview.close();
137
- recipeReload.close();
138
- runUiRuntime.shutdown(reason, ctx);
148
+ const ownerId = runOwnerIdsByContext.get(ctx);
149
+ runOwnerIdsByContext.delete(ctx);
150
+ if (activeRunContext === ctx) closeActiveSessionRuntimes();
151
+ if (ownerId) recipeResolutionContextsBySession.delete(ownerId);
152
+ runUiRuntime.shutdown(reason, ownerId, ctx);
139
153
  },
140
154
  async onSessionStart(ctx) {
141
155
  const sessionId = getRunOwnerId(ctx);
@@ -145,13 +159,15 @@ export function createActorExtensionRuntime(
145
159
  );
146
160
  ctx.ui.setWidget("zz-pi-actors-comms", undefined);
147
161
  activeRunContext = ctx;
162
+ activeRunOwnerId = sessionId;
163
+ runOwnerIdsByContext.set(ctx, sessionId);
148
164
  runUiRuntime.close();
149
165
  automaticReview.close();
150
166
  recipeReload.close();
151
167
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
152
- if (activeRunContext !== ctx) return;
168
+ if (activeRunContext !== ctx || activeRunOwnerId !== sessionId) return;
153
169
  automaticReview.start(ctx);
154
- runUiRuntime.start(ctx);
170
+ runUiRuntime.start(ctx, sessionId);
155
171
  recipeReload.watch(ctx);
156
172
  },
157
173
  registerCoreTools() {
@@ -418,7 +418,11 @@ export function createRunTerminalReconciliationLoop(input: {
418
418
  input.refreshWatcher();
419
419
  input.reconcile();
420
420
  } catch (error) {
421
- input.onError?.(error);
421
+ try {
422
+ input.onError?.(error);
423
+ } catch {
424
+ /* reconciliation callbacks must never escape into the host event loop */
425
+ }
422
426
  }
423
427
  };
424
428
  const close = (): void => {
@@ -11,25 +11,89 @@ import * as Pi from "./pi.ts";
11
11
 
12
12
  export interface RunUiRuntime {
13
13
  close(): void;
14
- shutdown(eventReason: string, ctx: Pi.ExtensionContext): void;
15
- start(ctx: Pi.ExtensionContext): void;
14
+ shutdown(
15
+ eventReason: string,
16
+ ownerId: string | undefined,
17
+ ctx?: Pi.ExtensionContext,
18
+ ): void;
19
+ start(ctx: Pi.ExtensionContext, ownerId: string): void;
16
20
  }
17
21
 
18
22
  export interface RunUiRuntimeDeps {
23
+ animationIntervalMs?: number;
24
+ createRunStateWatcher?: typeof Observability.createRunStateWatcher;
25
+ createRunTerminalReconciliationLoop?:
26
+ typeof Observability.createRunTerminalReconciliationLoop;
19
27
  getActiveContext(): Pi.ExtensionContext | undefined;
20
- getRunOwnerId(ctx: Pi.ExtensionContext): string;
28
+ notificationDelayMs?: number;
29
+ onCallbackError?: (error: unknown) => void;
21
30
  onRunEvent(): void;
22
31
  pi: Pi.ExtensionAPI;
32
+ teardownRunsOwnedByParent?: typeof AsyncRuns.teardownRunsOwnedByParent;
23
33
  }
24
34
 
25
35
  export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
36
+ let activeContext: Pi.ExtensionContext | undefined;
37
+ let activeOwnerId: string | undefined;
26
38
  let animationInterval: NodeJS.Timeout | undefined;
27
39
  let notifyTimeout: NodeJS.Timeout | undefined;
40
+ let running = false;
28
41
  let lastWatcherDiagnosticId = 0;
29
42
  const observation = Observability.createRunUiObservationState();
30
43
  const retirementAttempts = new Set<string>();
31
44
  const terminalNotificationsInFlight = new Set<string>();
32
45
 
46
+ const close = (): void => {
47
+ running = false;
48
+ activeContext = undefined;
49
+ activeOwnerId = undefined;
50
+ try {
51
+ watcher.close();
52
+ } catch {
53
+ /* cleanup must not escape a host callback */
54
+ }
55
+ try {
56
+ reconciliation.close();
57
+ } catch {
58
+ /* cleanup must not escape a host callback */
59
+ }
60
+ if (notifyTimeout) clearTimeout(notifyTimeout);
61
+ notifyTimeout = undefined;
62
+ if (animationInterval) clearInterval(animationInterval);
63
+ animationInterval = undefined;
64
+ };
65
+ const stopAfterCallbackFailure = (
66
+ label: string,
67
+ error: unknown,
68
+ expectedContext: Pi.ExtensionContext,
69
+ ): void => {
70
+ if (activeContext !== expectedContext) return;
71
+ close();
72
+ try {
73
+ deps.onCallbackError?.(error);
74
+ } catch {
75
+ /* host callback containment must remain no-throw */
76
+ }
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ try {
79
+ expectedContext.ui.notify(`Actor ${label} failed: ${message}`, "error");
80
+ } catch {
81
+ /* stale context or unavailable UI */
82
+ }
83
+ };
84
+ const runActiveCallback = (
85
+ label: string,
86
+ callback: (ctx: Pi.ExtensionContext, ownerId: string) => void,
87
+ ): void => {
88
+ if (!running || !activeContext || !activeOwnerId) return;
89
+ const ctx = activeContext;
90
+ try {
91
+ if (deps.getActiveContext() !== ctx) return;
92
+ callback(ctx, activeOwnerId);
93
+ } catch (error) {
94
+ stopAfterCallbackFailure(label, error, ctx);
95
+ }
96
+ };
33
97
  const retireCandidateRuns = (
34
98
  ctx: Pi.ExtensionContext,
35
99
  summary: Observability.RunSummary,
@@ -39,14 +103,16 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
39
103
  cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
40
104
  notify: (message, level) => ctx.ui.notify(message, level),
41
105
  sendStop: async (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
42
- });
106
+ }).catch((error) =>
107
+ stopAfterCallbackFailure("Run retirement callback", error, ctx),
108
+ );
43
109
  };
44
110
  const update = (
45
111
  ctx: Pi.ExtensionContext,
112
+ ownerId: string,
46
113
  notify = false,
47
114
  terminalOnly = false,
48
115
  ): void => {
49
- const ownerId = deps.getRunOwnerId(ctx);
50
116
  const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
51
117
  ctx.ui.setStatus(
52
118
  "zz-pi-actors-runs",
@@ -76,61 +142,63 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
76
142
  }
77
143
  };
78
144
  const scheduleUpdate = (): void => {
145
+ if (!running) return;
79
146
  if (notifyTimeout) clearTimeout(notifyTimeout);
80
147
  notifyTimeout = setTimeout(() => {
81
- const ctx = deps.getActiveContext();
82
- if (!ctx) return;
83
- watcher.refresh();
84
- update(ctx, true);
85
- deps.onRunEvent();
86
- reportDiagnostics(ctx);
87
- }, 50);
148
+ runActiveCallback("Run watcher callback", (ctx, ownerId) => {
149
+ watcher.refresh();
150
+ update(ctx, ownerId, true);
151
+ deps.onRunEvent();
152
+ reportDiagnostics(ctx);
153
+ });
154
+ }, deps.notificationDelayMs ?? 50);
88
155
  notifyTimeout.unref?.();
89
156
  };
90
- const watcher = Observability.createRunStateWatcher({
157
+ const watcher = (deps.createRunStateWatcher ?? Observability.createRunStateWatcher)({
91
158
  stateRoot: Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
92
159
  onChange: scheduleUpdate,
93
160
  });
94
- const reconciliation = Observability.createRunTerminalReconciliationLoop({
161
+ const reconciliation = (
162
+ deps.createRunTerminalReconciliationLoop ??
163
+ Observability.createRunTerminalReconciliationLoop
164
+ )({
95
165
  onError: (error) => {
96
- const ctx = deps.getActiveContext();
97
- if (!ctx) return;
98
- const message = error instanceof Error ? error.message : String(error);
99
- ctx.ui.notify(`Actor terminal reconciliation failed: ${message}`, "error");
166
+ if (!running || !activeContext) return;
167
+ stopAfterCallbackFailure(
168
+ "terminal reconciliation callback",
169
+ error,
170
+ activeContext,
171
+ );
100
172
  },
101
173
  reconcile: () => {
102
- const ctx = deps.getActiveContext();
103
- if (!ctx) return;
104
- Observability.reconcileRunTerminalNotifications({
105
- inFlight: terminalNotificationsInFlight,
106
- ownerId: deps.getRunOwnerId(ctx),
107
- sink: Pi.createNotificationSink(deps.pi, ctx),
108
- state: observation,
109
- includeAttention: true,
174
+ runActiveCallback("terminal reconciliation callback", (ctx, ownerId) => {
175
+ Observability.reconcileRunTerminalNotifications({
176
+ inFlight: terminalNotificationsInFlight,
177
+ ownerId,
178
+ sink: Pi.createNotificationSink(deps.pi, ctx),
179
+ state: observation,
180
+ includeAttention: true,
181
+ });
182
+ reportDiagnostics(ctx);
110
183
  });
111
- reportDiagnostics(ctx);
112
184
  },
113
- refreshWatcher: () => watcher.refresh(),
185
+ refreshWatcher: () => {
186
+ if (running) watcher.refresh();
187
+ },
114
188
  });
115
- const close = (): void => {
116
- watcher.close();
117
- reconciliation.close();
118
- if (notifyTimeout) clearTimeout(notifyTimeout);
119
- notifyTimeout = undefined;
120
- if (animationInterval) clearInterval(animationInterval);
121
- animationInterval = undefined;
122
- };
123
189
 
124
190
  return {
125
191
  close,
126
- shutdown(eventReason, ctx) {
127
- close();
128
- const teardown = AsyncRuns.teardownRunsOwnedByParent(
129
- deps.getRunOwnerId(ctx),
192
+ shutdown(eventReason, ownerId, ctx) {
193
+ if (!ownerId) return;
194
+ const teardown = (
195
+ deps.teardownRunsOwnedByParent ?? AsyncRuns.teardownRunsOwnedByParent
196
+ )(
197
+ ownerId,
130
198
  Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
131
199
  { trigger: `session_shutdown:${eventReason}` },
132
200
  );
133
- if (teardown.failed === 0) return;
201
+ if (teardown.failed === 0 || !ctx) return;
134
202
  try {
135
203
  ctx.ui.notify(
136
204
  `Actor shutdown teardown: killed=${teardown.killed} failed=${teardown.failed} skipped=${teardown.skipped} discovery_failed=${teardown.discoveryFailed}. Summary: ${teardown.summaryPath ?? "unavailable"}.`,
@@ -140,16 +208,26 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
140
208
  /* stale shutdown context */
141
209
  }
142
210
  },
143
- start(ctx) {
211
+ start(ctx, ownerId) {
144
212
  close();
145
- Observability.primeRunAttentionState(observation, deps.getRunOwnerId(ctx));
146
- update(ctx, true, true);
147
- watcher.refresh();
148
- reconciliation.start();
149
- animationInterval = setInterval(() => {
150
- if (deps.getActiveContext() === ctx) update(ctx);
151
- }, 1000);
152
- animationInterval.unref?.();
213
+ activeContext = ctx;
214
+ activeOwnerId = ownerId;
215
+ running = true;
216
+ try {
217
+ Observability.primeRunAttentionState(observation, ownerId);
218
+ update(ctx, ownerId, true, true);
219
+ watcher.refresh();
220
+ reconciliation.start();
221
+ animationInterval = setInterval(() => {
222
+ runActiveCallback("status animation callback", (current, currentOwnerId) =>
223
+ update(current, currentOwnerId),
224
+ );
225
+ }, deps.animationIntervalMs ?? 1000);
226
+ animationInterval.unref?.();
227
+ } catch (error) {
228
+ close();
229
+ throw error;
230
+ }
153
231
  },
154
232
  };
155
233
  }
package/lib/runtime.ts CHANGED
@@ -308,7 +308,9 @@ export function createAutoToolsRuntime(
308
308
  export interface RecipeToolReloadWatcherDeps {
309
309
  exists?: (path: string) => boolean;
310
310
  getResolutionContext?: () => RecipeResolutionContext | undefined;
311
+ onCallbackError?: (error: unknown) => void;
311
312
  recipeRoot?: string;
313
+ reloadDelayMs?: number;
312
314
  watchPath?: typeof watch;
313
315
  }
314
316
 
@@ -336,22 +338,39 @@ export function createRecipeToolReloadWatcher(
336
338
  reloadTimeout = undefined;
337
339
  setWatchStatus("closed");
338
340
  };
341
+ const reportCallbackError = (error: unknown): void => {
342
+ try {
343
+ deps.onCallbackError?.(error);
344
+ } catch {
345
+ /* host callback containment must remain no-throw */
346
+ }
347
+ };
339
348
  const notifyFailure = (ctx: RuntimeContext): void => {
340
349
  if (failureNotified) return;
341
350
  failureNotified = true;
342
351
  setWatchStatus("failed");
343
- ctx.ui.notify(
344
- "Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.",
345
- "warning",
346
- );
352
+ try {
353
+ ctx.ui.notify(
354
+ "Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.",
355
+ "warning",
356
+ );
357
+ } catch (error) {
358
+ reportCallbackError(error);
359
+ }
347
360
  };
348
361
  const scheduleReload = (ctx: RuntimeContext): void => {
349
362
  failureNotified = false;
350
363
  if (reloadTimeout) clearTimeout(reloadTimeout);
351
364
  reloadTimeout = setTimeout(() => {
352
- runtime.loadTools(ctx, deps.getResolutionContext?.());
353
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
354
- }, 150);
365
+ reloadTimeout = undefined;
366
+ try {
367
+ runtime.loadTools(ctx, deps.getResolutionContext?.());
368
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
369
+ } catch (error) {
370
+ notifyFailure(ctx);
371
+ reportCallbackError(error);
372
+ }
373
+ }, deps.reloadDelayMs ?? 150);
355
374
  reloadTimeout.unref?.();
356
375
  };
357
376
  const watchParent = (ctx: RuntimeContext, recipeRoot: string): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.49.1",
3
+ "version": "0.49.2",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [