@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 +4 -4
- package/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
- package/src/index.ts +52 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/subagents",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
21
|
-
"@pi-unipi/workflow": "2.
|
|
22
|
-
"@pi-unipi/info-screen": "2.
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
});
|