@pi-unipi/subagents 2.4.0 → 2.4.1
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/README.md +3 -1
- package/dist/agent-manager.d.ts +81 -0
- package/dist/agent-manager.d.ts.map +1 -0
- package/dist/agent-manager.js +292 -0
- package/dist/agent-manager.js.map +1 -0
- package/dist/agent-runner.d.ts +51 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +262 -0
- package/dist/agent-runner.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/conversation-viewer.d.ts +40 -0
- package/dist/conversation-viewer.d.ts.map +1 -0
- package/dist/conversation-viewer.js +276 -0
- package/dist/conversation-viewer.js.map +1 -0
- package/dist/core-compat.d.ts +14 -0
- package/dist/core-compat.d.ts.map +1 -0
- package/dist/core-compat.js +24 -0
- package/dist/core-compat.js.map +1 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.d.ts.map +1 -0
- package/dist/custom-agents.js +106 -0
- package/dist/custom-agents.js.map +1 -0
- package/dist/file-lock.d.ts +42 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +91 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +751 -0
- package/dist/index.js.map +1 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.d.ts.map +1 -0
- package/dist/model-resolver.js +61 -0
- package/dist/model-resolver.js.map +1 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +47 -0
- package/dist/types.js.map +1 -0
- package/dist/widget.d.ts +56 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +396 -0
- package/dist/widget.js.map +1 -0
- package/package.json +10 -6
- package/src/__tests__/badge-generation.test.ts +0 -315
- package/src/__tests__/config.test.ts +0 -240
- package/src/__tests__/esc-propagation.test.ts +0 -162
- package/src/__tests__/file-lock.test.ts +0 -244
- package/src/__tests__/shutdown-stale-ctx.test.ts +0 -185
- package/src/__tests__/workflow-integration.test.ts +0 -334
- package/src/agent-manager.ts +0 -334
- package/src/agent-runner.ts +0 -329
- package/src/config.ts +0 -147
- package/src/conversation-viewer.ts +0 -299
- package/src/custom-agents.ts +0 -118
- package/src/file-lock.ts +0 -102
- package/src/index.ts +0 -862
- package/src/model-resolver.ts +0 -79
- package/src/prompts.ts +0 -39
- package/src/skills/explore/SKILL.md +0 -32
- package/src/skills/work/SKILL.md +0 -40
- package/src/types.ts +0 -146
- package/src/widget.ts +0 -454
- package/tsconfig.json +0 -19
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: ESC propagation — all children abort on parent ESC
|
|
3
|
-
*
|
|
4
|
-
* Verifies:
|
|
5
|
-
* - forwardAbortSignal wires parent signal to child session
|
|
6
|
-
* - abortAll stops all running agents
|
|
7
|
-
* - All agents stop within reasonable time
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { describe, it, mock, beforeEach } from "node:test";
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
|
|
13
|
-
// Mock AbortController to track abort calls
|
|
14
|
-
function createMockAbortController() {
|
|
15
|
-
let aborted = false;
|
|
16
|
-
const listeners: Array<() => void> = [];
|
|
17
|
-
return {
|
|
18
|
-
get signal() {
|
|
19
|
-
return {
|
|
20
|
-
aborted,
|
|
21
|
-
addEventListener: (_event: string, listener: () => void) => {
|
|
22
|
-
listeners.push(listener);
|
|
23
|
-
},
|
|
24
|
-
removeEventListener: (_event: string, listener: () => void) => {
|
|
25
|
-
const idx = listeners.indexOf(listener);
|
|
26
|
-
if (idx !== -1) listeners.splice(idx, 1);
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
},
|
|
30
|
-
abort() {
|
|
31
|
-
aborted = true;
|
|
32
|
-
for (const listener of listeners) listener();
|
|
33
|
-
},
|
|
34
|
-
get wasAborted() {
|
|
35
|
-
return aborted;
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("ESC Propagation", () => {
|
|
41
|
-
describe("forwardAbortSignal", () => {
|
|
42
|
-
it("should call session.abort() when signal fires", () => {
|
|
43
|
-
// Simulate the forwardAbortSignal logic from agent-runner.ts
|
|
44
|
-
const sessionAborted = { value: false };
|
|
45
|
-
const session = { abort: () => { sessionAborted.value = true; } };
|
|
46
|
-
const controller = createMockAbortController();
|
|
47
|
-
|
|
48
|
-
// Wire abort signal
|
|
49
|
-
const onAbort = () => session.abort();
|
|
50
|
-
controller.signal.addEventListener("abort", onAbort);
|
|
51
|
-
|
|
52
|
-
// Trigger abort
|
|
53
|
-
controller.abort();
|
|
54
|
-
|
|
55
|
-
assert.equal(sessionAborted.value, true, "Session should be aborted");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("should not call session.abort() if signal not fired", () => {
|
|
59
|
-
const sessionAborted = { value: false };
|
|
60
|
-
const session = { abort: () => { sessionAborted.value = true; } };
|
|
61
|
-
const controller = createMockAbortController();
|
|
62
|
-
|
|
63
|
-
const onAbort = () => session.abort();
|
|
64
|
-
controller.signal.addEventListener("abort", onAbort);
|
|
65
|
-
|
|
66
|
-
// Don't abort
|
|
67
|
-
assert.equal(sessionAborted.value, false, "Session should not be aborted");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("should cleanup listener when returned function called", () => {
|
|
71
|
-
const controller = createMockAbortController();
|
|
72
|
-
let callCount = 0;
|
|
73
|
-
const onAbort = () => { callCount++; };
|
|
74
|
-
controller.signal.addEventListener("abort", onAbort);
|
|
75
|
-
|
|
76
|
-
// Simulate cleanup
|
|
77
|
-
const cleanup = () => controller.signal.removeEventListener("abort", onAbort);
|
|
78
|
-
cleanup();
|
|
79
|
-
|
|
80
|
-
controller.abort();
|
|
81
|
-
assert.equal(callCount, 0, "Listener should not fire after cleanup");
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("abortAll", () => {
|
|
86
|
-
it("should abort all running agents", () => {
|
|
87
|
-
const agents = new Map<string, { abortController: ReturnType<typeof createMockAbortController>; status: string }>();
|
|
88
|
-
|
|
89
|
-
// Create 3 mock agents
|
|
90
|
-
for (let i = 0; i < 3; i++) {
|
|
91
|
-
const controller = createMockAbortController();
|
|
92
|
-
agents.set(`agent-${i}`, {
|
|
93
|
-
abortController: controller,
|
|
94
|
-
status: "running",
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Simulate abortAll
|
|
99
|
-
let abortedCount = 0;
|
|
100
|
-
for (const [id, record] of agents) {
|
|
101
|
-
if (record.status === "running") {
|
|
102
|
-
record.abortController.abort();
|
|
103
|
-
record.status = "stopped";
|
|
104
|
-
abortedCount++;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
assert.equal(abortedCount, 3, "Should abort all 3 agents");
|
|
109
|
-
for (const [_, record] of agents) {
|
|
110
|
-
assert.equal(record.status, "stopped", "All agents should be stopped");
|
|
111
|
-
assert.equal(record.abortController.wasAborted, true, "All controllers should be aborted");
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("should handle queued agents by removing from queue", () => {
|
|
116
|
-
const queue = [
|
|
117
|
-
{ id: "queued-1", status: "queued" },
|
|
118
|
-
{ id: "queued-2", status: "queued" },
|
|
119
|
-
];
|
|
120
|
-
const agents = new Map<string, { status: string }>();
|
|
121
|
-
|
|
122
|
-
for (const item of queue) {
|
|
123
|
-
agents.set(item.id, { status: item.status });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Simulate abortAll for queued
|
|
127
|
-
for (const item of queue) {
|
|
128
|
-
const record = agents.get(item.id);
|
|
129
|
-
if (record) {
|
|
130
|
-
record.status = "stopped";
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
queue.length = 0;
|
|
134
|
-
|
|
135
|
-
assert.equal(queue.length, 0, "Queue should be empty");
|
|
136
|
-
for (const [_, record] of agents) {
|
|
137
|
-
assert.equal(record.status, "stopped", "All queued agents should be stopped");
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("ESC timing", () => {
|
|
143
|
-
it("should abort within reasonable time", async () => {
|
|
144
|
-
const controller = createMockAbortController();
|
|
145
|
-
let abortedAt: number | null = null;
|
|
146
|
-
const startedAt = Date.now();
|
|
147
|
-
|
|
148
|
-
const onAbort = () => { abortedAt = Date.now(); };
|
|
149
|
-
controller.signal.addEventListener("abort", onAbort);
|
|
150
|
-
|
|
151
|
-
// Simulate abort after small delay
|
|
152
|
-
setTimeout(() => controller.abort(), 10);
|
|
153
|
-
|
|
154
|
-
// Wait for abort
|
|
155
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
156
|
-
|
|
157
|
-
assert.notEqual(abortedAt, null, "Should have aborted");
|
|
158
|
-
const elapsed = abortedAt! - startedAt;
|
|
159
|
-
assert.ok(elapsed < 500, `Abort should happen within 500ms, took ${elapsed}ms`);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
});
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: File locking — concurrent writes to same file queue correctly
|
|
3
|
-
*
|
|
4
|
-
* Verifies:
|
|
5
|
-
* - Per-file locking works correctly
|
|
6
|
-
* - Same file writes queue (second waits for first)
|
|
7
|
-
* - Different file writes proceed in parallel
|
|
8
|
-
* - Lock release unblocks waiting acquires
|
|
9
|
-
* - releaseAll releases all locks for an agent
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { describe, it } from "node:test";
|
|
13
|
-
import assert from "node:assert/strict";
|
|
14
|
-
|
|
15
|
-
// Inline FileLock implementation for testing (avoids TS import issues)
|
|
16
|
-
interface FileLockEntry {
|
|
17
|
-
agentId: string;
|
|
18
|
-
filePath: string;
|
|
19
|
-
promise: Promise<void>;
|
|
20
|
-
release: () => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
class FileLock {
|
|
24
|
-
private locks = new Map<string, FileLockEntry>();
|
|
25
|
-
private queues = new Map<string, Array<() => void>>();
|
|
26
|
-
|
|
27
|
-
async acquire(filePath: string, agentId: string): Promise<() => void> {
|
|
28
|
-
while (this.locks.has(filePath)) {
|
|
29
|
-
await new Promise<void>((resolve) => {
|
|
30
|
-
const queue = this.queues.get(filePath) ?? [];
|
|
31
|
-
queue.push(resolve);
|
|
32
|
-
this.queues.set(filePath, queue);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
let releaseFn: () => void;
|
|
37
|
-
const promise = new Promise<void>((resolve) => {
|
|
38
|
-
releaseFn = () => {
|
|
39
|
-
this.locks.delete(filePath);
|
|
40
|
-
resolve();
|
|
41
|
-
const queue = this.queues.get(filePath);
|
|
42
|
-
if (queue && queue.length > 0) {
|
|
43
|
-
const next = queue.shift()!;
|
|
44
|
-
next();
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const entry: FileLockEntry = {
|
|
50
|
-
agentId,
|
|
51
|
-
filePath,
|
|
52
|
-
promise,
|
|
53
|
-
release: releaseFn!,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
this.locks.set(filePath, entry);
|
|
57
|
-
return releaseFn!;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
isLocked(filePath: string): boolean {
|
|
61
|
-
return this.locks.has(filePath);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
getHolder(filePath: string): string | undefined {
|
|
65
|
-
return this.locks.get(filePath)?.agentId;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
get lockCount(): number {
|
|
69
|
-
return this.locks.size;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
releaseAll(agentId: string): void {
|
|
73
|
-
for (const [filePath, entry] of this.locks) {
|
|
74
|
-
if (entry.agentId === agentId) {
|
|
75
|
-
entry.release();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
clear(): void {
|
|
81
|
-
for (const entry of this.locks.values()) {
|
|
82
|
-
entry.release();
|
|
83
|
-
}
|
|
84
|
-
this.locks.clear();
|
|
85
|
-
this.queues.clear();
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
describe("FileLock", () => {
|
|
90
|
-
describe("Basic locking", () => {
|
|
91
|
-
it("should acquire lock on unlocked file", async () => {
|
|
92
|
-
const lock = new FileLock();
|
|
93
|
-
const release = await lock.acquire("/src/auth.ts", "agent-1");
|
|
94
|
-
|
|
95
|
-
assert.equal(lock.isLocked("/src/auth.ts"), true);
|
|
96
|
-
assert.equal(lock.getHolder("/src/auth.ts"), "agent-1");
|
|
97
|
-
assert.equal(lock.lockCount, 1);
|
|
98
|
-
|
|
99
|
-
release();
|
|
100
|
-
assert.equal(lock.isLocked("/src/auth.ts"), false);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("should track multiple locks on different files", async () => {
|
|
104
|
-
const lock = new FileLock();
|
|
105
|
-
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
106
|
-
const release2 = await lock.acquire("/src/login.ts", "agent-2");
|
|
107
|
-
|
|
108
|
-
assert.equal(lock.lockCount, 2);
|
|
109
|
-
assert.equal(lock.getHolder("/src/auth.ts"), "agent-1");
|
|
110
|
-
assert.equal(lock.getHolder("/src/login.ts"), "agent-2");
|
|
111
|
-
|
|
112
|
-
release1();
|
|
113
|
-
release2();
|
|
114
|
-
assert.equal(lock.lockCount, 0);
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe("Queuing behavior", () => {
|
|
119
|
-
it("should queue second acquire on same file", async () => {
|
|
120
|
-
const lock = new FileLock();
|
|
121
|
-
const events: string[] = [];
|
|
122
|
-
|
|
123
|
-
// First acquire
|
|
124
|
-
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
125
|
-
events.push("agent-1-acquired");
|
|
126
|
-
|
|
127
|
-
// Second acquire (should queue)
|
|
128
|
-
const acquire2Promise = lock.acquire("/src/auth.ts", "agent-2").then((release) => {
|
|
129
|
-
events.push("agent-2-acquired");
|
|
130
|
-
return release;
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// agent-2 should not have acquired yet
|
|
134
|
-
assert.deepEqual(events, ["agent-1-acquired"]);
|
|
135
|
-
|
|
136
|
-
// Release first lock
|
|
137
|
-
release1();
|
|
138
|
-
events.push("agent-1-released");
|
|
139
|
-
|
|
140
|
-
// Wait for agent-2
|
|
141
|
-
const release2 = await acquire2Promise;
|
|
142
|
-
assert.deepEqual(events, ["agent-1-acquired", "agent-1-released", "agent-2-acquired"]);
|
|
143
|
-
|
|
144
|
-
release2();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("should queue multiple acquires on same file", async () => {
|
|
148
|
-
const lock = new FileLock();
|
|
149
|
-
const events: string[] = [];
|
|
150
|
-
|
|
151
|
-
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
152
|
-
events.push("1-acquired");
|
|
153
|
-
|
|
154
|
-
const p2 = lock.acquire("/src/auth.ts", "agent-2").then(r => { events.push("2-acquired"); return r; });
|
|
155
|
-
const p3 = lock.acquire("/src/auth.ts", "agent-3").then(r => { events.push("3-acquired"); return r; });
|
|
156
|
-
|
|
157
|
-
release1();
|
|
158
|
-
const release2 = await p2;
|
|
159
|
-
assert.deepEqual(events, ["1-acquired", "2-acquired"]);
|
|
160
|
-
|
|
161
|
-
release2();
|
|
162
|
-
const release3 = await p3;
|
|
163
|
-
assert.deepEqual(events, ["1-acquired", "2-acquired", "3-acquired"]);
|
|
164
|
-
|
|
165
|
-
release3();
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
describe("Parallel different files", () => {
|
|
170
|
-
it("should allow parallel writes to different files", async () => {
|
|
171
|
-
const lock = new FileLock();
|
|
172
|
-
const events: string[] = [];
|
|
173
|
-
|
|
174
|
-
// Both should acquire immediately (different files)
|
|
175
|
-
const release1 = await lock.acquire("/src/auth.ts", "agent-1");
|
|
176
|
-
events.push("auth-acquired");
|
|
177
|
-
|
|
178
|
-
const release2 = await lock.acquire("/src/login.ts", "agent-2");
|
|
179
|
-
events.push("login-acquired");
|
|
180
|
-
|
|
181
|
-
assert.deepEqual(events, ["auth-acquired", "login-acquired"]);
|
|
182
|
-
assert.equal(lock.lockCount, 2);
|
|
183
|
-
|
|
184
|
-
release1();
|
|
185
|
-
release2();
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe("releaseAll", () => {
|
|
190
|
-
it("should release all locks for a specific agent", async () => {
|
|
191
|
-
const lock = new FileLock();
|
|
192
|
-
|
|
193
|
-
// agent-1 holds 3 files
|
|
194
|
-
await lock.acquire("/src/a.ts", "agent-1");
|
|
195
|
-
await lock.acquire("/src/b.ts", "agent-1");
|
|
196
|
-
await lock.acquire("/src/c.ts", "agent-1");
|
|
197
|
-
|
|
198
|
-
// agent-2 holds 1 file
|
|
199
|
-
await lock.acquire("/src/d.ts", "agent-2");
|
|
200
|
-
|
|
201
|
-
assert.equal(lock.lockCount, 4);
|
|
202
|
-
|
|
203
|
-
// Release all for agent-1
|
|
204
|
-
lock.releaseAll("agent-1");
|
|
205
|
-
|
|
206
|
-
assert.equal(lock.lockCount, 1);
|
|
207
|
-
assert.equal(lock.isLocked("/src/a.ts"), false);
|
|
208
|
-
assert.equal(lock.isLocked("/src/b.ts"), false);
|
|
209
|
-
assert.equal(lock.isLocked("/src/c.ts"), false);
|
|
210
|
-
assert.equal(lock.isLocked("/src/d.ts"), true);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("should unblock queued acquires when releasing all", async () => {
|
|
214
|
-
const lock = new FileLock();
|
|
215
|
-
const events: string[] = [];
|
|
216
|
-
|
|
217
|
-
const release1 = await lock.acquire("/src/a.ts", "agent-1");
|
|
218
|
-
const p2 = lock.acquire("/src/a.ts", "agent-2").then(r => { events.push("agent-2-acquired"); return r; });
|
|
219
|
-
|
|
220
|
-
// Release all for agent-1
|
|
221
|
-
lock.releaseAll("agent-1");
|
|
222
|
-
|
|
223
|
-
const release2 = await p2;
|
|
224
|
-
assert.deepEqual(events, ["agent-2-acquired"]);
|
|
225
|
-
|
|
226
|
-
release2();
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
describe("clear", () => {
|
|
231
|
-
it("should release all locks and clear queues", async () => {
|
|
232
|
-
const lock = new FileLock();
|
|
233
|
-
|
|
234
|
-
await lock.acquire("/src/a.ts", "agent-1");
|
|
235
|
-
await lock.acquire("/src/b.ts", "agent-2");
|
|
236
|
-
|
|
237
|
-
lock.clear();
|
|
238
|
-
|
|
239
|
-
assert.equal(lock.lockCount, 0);
|
|
240
|
-
assert.equal(lock.isLocked("/src/a.ts"), false);
|
|
241
|
-
assert.equal(lock.isLocked("/src/b.ts"), false);
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,185 +0,0 @@
|
|
|
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
|
-
});
|