@pi-unipi/subagents 2.1.3 → 2.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,9 +17,9 @@
17
17
  "test": "npx tsx --test src/__tests__/*.test.ts"
18
18
  },
19
19
  "dependencies": {
20
- "@pi-unipi/core": "2.1.3",
21
- "@pi-unipi/workflow": "2.1.3",
22
- "@pi-unipi/info-screen": "2.1.3",
20
+ "@pi-unipi/core": "2.2.0",
21
+ "@pi-unipi/workflow": "2.2.0",
22
+ "@pi-unipi/info-screen": "2.2.0",
23
23
  "@earendil-works/pi-agent-core": "^0.80.0"
24
24
  },
25
25
  "devDependencies": {
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Test: background agents must not touch a stale extension runtime
3
+ *
4
+ * BUG — Pi crashed on exit with:
5
+ * "This extension ctx is stale after session replacement or reload."
6
+ * at pi.sendMessage (loader.js:240)
7
+ * at onComplete (packages/subagents/src/index.ts)
8
+ *
9
+ * Sequence:
10
+ * 1. A background agent (e.g. the session-name generator) is still running.
11
+ * 2. The user quits. Pi emits `session_shutdown`, then immediately calls
12
+ * `AgentSession.dispose()`, which invalidates the extension runtime.
13
+ * 3. Our handler calls `abortAll()`, but aborting only *signals* the
14
+ * AbortController — the in-flight promise still settles on a later
15
+ * microtask and invokes the completion callback.
16
+ * 4. That callback called `pi.sendMessage()`, which is `assertActive`-gated,
17
+ * so it threw from an async continuation with no catch → process crash.
18
+ *
19
+ * FIX — a `sessionEnded` flag set at the top of the `session_shutdown` handler
20
+ * (before `abortAll()`), checked at the top of the completion callback, plus a
21
+ * try/catch around `sendMessage` for the session-replacement race.
22
+ *
23
+ * The flag is scoped to the extension factory, not the module: `/new`,
24
+ * `/fork` and `/resume` also emit `session_shutdown` and pi re-invokes the
25
+ * factory for the replacement session, so a fresh closure resets it.
26
+ * (Verified against pi 0.80.2: `/new` emits `shutdown reason=new`, then the
27
+ * factory runs again with `ended=false`.)
28
+ */
29
+
30
+ import { describe, it } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { readFileSync, existsSync } from "node:fs";
33
+ import { join } from "node:path";
34
+
35
+ const ROOT = join(import.meta.dirname, "../../../..");
36
+
37
+ function readSource(relativePath: string): string {
38
+ const fullPath = join(ROOT, relativePath);
39
+ if (!existsSync(fullPath)) throw new Error(`File not found: ${fullPath}`);
40
+ return readFileSync(fullPath, "utf-8");
41
+ }
42
+
43
+ // ─── Behavioural: reproduce the race ────────────────────────────────
44
+
45
+ /**
46
+ * Minimal stand-in for pi's runtime: every `assertActive`-gated method throws
47
+ * once the session has been disposed.
48
+ */
49
+ function createFakeRuntime() {
50
+ let stale = false;
51
+ return {
52
+ invalidate() {
53
+ stale = true;
54
+ },
55
+ sendMessage() {
56
+ if (stale) {
57
+ throw new Error(
58
+ "This extension ctx is stale after session replacement or reload.",
59
+ );
60
+ }
61
+ return { delivered: true };
62
+ },
63
+ setSessionName() {
64
+ if (stale) {
65
+ throw new Error(
66
+ "This extension ctx is stale after session replacement or reload.",
67
+ );
68
+ }
69
+ },
70
+ // pi.events is NOT assertActive-gated — it keeps working.
71
+ events: { emit() {} },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Reproduces the extension's shutdown wiring.
77
+ *
78
+ * @param guard - whether to apply the `sessionEnded` fix
79
+ */
80
+ function simulateShutdownRace(guard: boolean) {
81
+ const pi = createFakeRuntime();
82
+ let sessionEnded = false;
83
+ const errors: Error[] = [];
84
+
85
+ // The completion callback registered with AgentManager.
86
+ const onComplete = () => {
87
+ if (guard && sessionEnded) return;
88
+ pi.sendMessage();
89
+ };
90
+
91
+ // A background agent whose promise is still pending at shutdown.
92
+ let settle!: () => void;
93
+ const inFlight = new Promise<void>((resolve) => {
94
+ settle = resolve;
95
+ }).then(() => {
96
+ try {
97
+ onComplete();
98
+ } catch (error) {
99
+ // In production this is an unhandled rejection that kills the process.
100
+ errors.push(error as Error);
101
+ }
102
+ });
103
+
104
+ // --- session_shutdown handler ---
105
+ if (guard) sessionEnded = true; // must be set BEFORE abortAll()
106
+ settle(); // abortAll(): signals abort; the promise settles a tick later
107
+ // --- pi disposes the session right after handlers resolve ---
108
+ pi.invalidate();
109
+
110
+ return { done: inFlight, errors };
111
+ }
112
+
113
+ describe("shutdown race — background agent completing after dispose", () => {
114
+ it("reproduces the stale-ctx crash without the guard", async () => {
115
+ const { done, errors } = simulateShutdownRace(false);
116
+ await done;
117
+
118
+ assert.equal(errors.length, 1, "expected the unguarded path to throw");
119
+ assert.match(errors[0].message, /stale after session replacement/);
120
+ });
121
+
122
+ it("does not touch the stale runtime with the guard", async () => {
123
+ const { done, errors } = simulateShutdownRace(true);
124
+ await done;
125
+
126
+ assert.deepEqual(errors, [], "guarded path must not throw");
127
+ });
128
+ });
129
+
130
+ // ─── Source contract: the wiring must stay correct ──────────────────
131
+
132
+ describe("subagents shutdown wiring", () => {
133
+ const src = readSource("packages/subagents/src/index.ts");
134
+
135
+ it("declares the sessionEnded guard", () => {
136
+ assert.match(
137
+ src,
138
+ /let sessionEnded = false;/,
139
+ "expected a `sessionEnded` guard flag",
140
+ );
141
+ });
142
+
143
+ it("sets the guard before aborting, inside session_shutdown", () => {
144
+ const handler = src.match(
145
+ /pi\.on\(\s*"session_shutdown"[\s\S]*?\}\s*\);/,
146
+ )?.[0];
147
+ assert.ok(handler, "session_shutdown handler not found");
148
+
149
+ const guardIdx = handler.indexOf("sessionEnded = true");
150
+ const abortIdx = handler.indexOf("abortAll()");
151
+
152
+ assert.notEqual(guardIdx, -1, "handler must set sessionEnded = true");
153
+ assert.notEqual(abortIdx, -1, "handler must still call abortAll()");
154
+ assert.ok(
155
+ guardIdx < abortIdx,
156
+ "sessionEnded must be set BEFORE abortAll(), which settles in-flight promises",
157
+ );
158
+ });
159
+
160
+ it("checks the guard before any pi call in the completion callback", () => {
161
+ const guardIdx = src.indexOf("if (sessionEnded) return;");
162
+ assert.notEqual(guardIdx, -1, "completion callback must bail when sessionEnded");
163
+
164
+ const sendIdx = src.indexOf("pi.sendMessage");
165
+ assert.notEqual(sendIdx, -1, "expected a pi.sendMessage call");
166
+ assert.ok(
167
+ guardIdx < sendIdx,
168
+ "the guard must be checked before pi.sendMessage is reached",
169
+ );
170
+ });
171
+
172
+ it("wraps sendMessage in try/catch for the session-replacement race", () => {
173
+ const block = src.match(/try\s*\{\s*pi\.sendMessage[\s\S]*?\}\s*catch\s*\{[\s\S]*?\}/);
174
+ assert.ok(
175
+ block,
176
+ "pi.sendMessage must be wrapped in try/catch — a notification is best-effort",
177
+ );
178
+ });
179
+
180
+ it("keeps setSessionName defensively wrapped", () => {
181
+ // Badge generation also runs on a background agent.
182
+ const block = src.match(/try\s*\{\s*pi\.setSessionName[\s\S]*?\}\s*catch/);
183
+ assert.ok(block, "pi.setSessionName must stay wrapped in try/catch");
184
+ });
185
+ });
package/src/index.ts CHANGED
@@ -137,10 +137,40 @@ export default function (pi: ExtensionAPI) {
137
137
  // Activity tracking for widget
138
138
  const agentActivity = new Map<string, AgentActivity>();
139
139
 
140
+ /**
141
+ * Set once `session_shutdown` fires — after which `pi` must not be touched.
142
+ *
143
+ * Pi disposes the session as soon as the shutdown handlers resolve, and
144
+ * `AgentSession.dispose()` invalidates the extension runtime. Every
145
+ * `assertActive`-gated method (`sendMessage`, `setSessionName`,
146
+ * `appendEntry`, `setModel`, …) then throws "This extension ctx is stale
147
+ * after session replacement or reload".
148
+ *
149
+ * Background agents outlive that moment: `abortAll()` only signals their
150
+ * AbortController, so the in-flight promise settles a microtask *later* and
151
+ * fires this completion callback against a dead runtime — an unhandled
152
+ * throw that crashed the process on exit.
153
+ *
154
+ * Scoped to the extension factory rather than module scope on purpose:
155
+ * `session_shutdown` also fires for `/new`, `/fork` and `/resume` (reasons
156
+ * "new" / "fork" / "resume"), and pi re-invokes the extension factory for
157
+ * the replacement session. A fresh closure therefore starts with
158
+ * `sessionEnded = false`, so the guard can never latch permanently.
159
+ * Verified: `/new` emits `shutdown reason=new` then re-runs the factory.
160
+ *
161
+ * `pi.events` is NOT gated, so cross-module events still fire.
162
+ */
163
+ let sessionEnded = false;
164
+
140
165
  // Create manager with completion callback
141
166
  const manager = new AgentManager(
142
167
  (record) => {
143
168
  agentActivity.delete(record.id);
169
+
170
+ // After shutdown the UI is gone and the runtime is stale — nothing here
171
+ // is deliverable, and touching `pi` would throw.
172
+ if (sessionEnded) return;
173
+
144
174
  widget.markFinished(record.id);
145
175
  widget.update();
146
176
 
@@ -160,6 +190,7 @@ export default function (pi: ExtensionAPI) {
160
190
  record.resultConsumed = true;
161
191
  }
162
192
 
193
+
163
194
  // Send styled notification via message renderer
164
195
  const status = getStatusLabel(record.status, record.error);
165
196
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
@@ -180,15 +211,22 @@ export default function (pi: ExtensionAPI) {
180
211
  ].join("\n");
181
212
 
182
213
  if (!record.resultConsumed) {
183
- pi.sendMessage<NotificationDetails>(
184
- {
185
- customType: "subagent-notification",
186
- content: notificationXml,
187
- display: true,
188
- details,
189
- },
190
- { deliverAs: "followUp", triggerTurn: true },
191
- );
214
+ // Defence in depth: `sessionEnded` covers the ordinary shutdown path,
215
+ // but a session can also be replaced mid-flight. Delivering a
216
+ // notification is best-effort — it must never take the process down.
217
+ try {
218
+ pi.sendMessage<NotificationDetails>(
219
+ {
220
+ customType: "subagent-notification",
221
+ content: notificationXml,
222
+ display: true,
223
+ details,
224
+ },
225
+ { deliverAs: "followUp", triggerTurn: true },
226
+ );
227
+ } catch {
228
+ // Runtime went stale between the guard and here — nothing to notify.
229
+ }
192
230
  }
193
231
 
194
232
  pi.events.emit("subagents:completed", {
@@ -394,8 +432,12 @@ export default function (pi: ExtensionAPI) {
394
432
  });
395
433
  });
396
434
 
397
- // ESC propagation: abort all agents on session shutdown
435
+ // ESC propagation: abort all agents on session shutdown.
436
+ // Set the guard FIRST: abortAll() settles in-flight promises, whose
437
+ // completion callbacks would otherwise reach a runtime that pi is about to
438
+ // invalidate.
398
439
  pi.on("session_shutdown", async () => {
440
+ sessionEnded = true;
399
441
  manager.abortAll();
400
442
  manager.dispose();
401
443
  });