@zhushanwen/pi-subagent-workflow 7.1.0 → 7.3.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.
@@ -226,7 +226,21 @@ export async function cancelHandler(
226
226
 
227
227
  // step 1: id 不存在(findRecord 只查内存 running record,不从 session.jsonl 重建)
228
228
  const rec = service.findRecord(id);
229
- if (!rec) throw new Error(`No subagent record with id "${id}". It may have finished — use action:'list' with includeFinished:true to verify.`);
229
+ if (!rec) {
230
+ // [S-19] MF-1 全树可见后,list/completion 可能列出其他进程(父/兄弟)的 running record
231
+ //(collectRecords 扫共享 sessionsDir 按 rootSessionId 过滤,跨进程互相可见),而 cancel
232
+ // 只作用于本进程内存 record。区分两种失败,避免「may have finished」误导(该 record 正
233
+ // 被列出且未 finished,只是不属于本进程内存)。仅文案区分,不改 cancel 作用域。
234
+ const treeRec = service.collectRecords(DEFAULT_LIST_LIMIT, "all").find((r) => r.id === id);
235
+ if (treeRec && treeRec.status === "running") {
236
+ throw new Error(
237
+ `Subagent record "${id}" is running but owned by another process in the tree ` +
238
+ `(it was spawned by a different subagent process) — this process cannot cancel it; ` +
239
+ `cancel only works for subagents spawned by the current process.`,
240
+ );
241
+ }
242
+ throw new Error(`No subagent record with id "${id}". It may have finished — use action:'list' with includeFinished:true to verify.`);
243
+ }
230
244
  // step 2: controller 检查(controller 为 undefined 表示 record 已终态或未启动)
231
245
  if (rec.mode !== "background") {
232
246
  throw new Error(`Cannot cancel subagent ${id} (unsupported mode: ${rec.mode})`);
@@ -32,7 +32,7 @@ export function registerSubagentsCommand(pi: ExtensionAPI): void {
32
32
  try {
33
33
  const service = getSubagentService();
34
34
  if (!service) return null;
35
- // collectRecords 合并内存(running) + 磁盘重建 record,按 session 过滤。
35
+ // collectRecords 合并内存(running) + 磁盘重建 record,按 rootSessionId 过滤。
36
36
  // cancel 只对 running 有效,但全部列出便于用户辨认(终态 record 会被 service 拒绝)。
37
37
  const records = service.collectRecords(LIST_LIMIT);
38
38
  if (records.length === 0) return null;
@@ -175,3 +175,55 @@ describe("W2: RunStore.stateFilePath 暴露 run 状态文件路径", () => {
175
175
  expect(result).toBe(path.join(tmpDir, "workflow-state", "run-xyz.jsonl"));
176
176
  });
177
177
  });
178
+
179
+ // W3: save 兜底容错——run 工作目录被并发清理时 mkdir 抛 ENOENT,save 静默返回。
180
+ //
181
+ // 防的 bug(PR #166 CI 回归):review-fix-loop-e2e 等 runAndWait 测试中,
182
+ // handleReturn 的 run.transition("done") 同步改 status 后,runAndWait 轮询发现 done
183
+ // 并 resolve,测试 afterEach 随即 rmSync 删除 sessionDir;此时 handleReturn 内 in-flight
184
+ // 的 await save 尚未完成,mkdir 遇到目录链被并发删除 → ENOENT。原实现 await save 让错误
185
+ // 冒泡为 unhandled promise rejection(worker-host onMessage 无 catch),CI exit 1。
186
+ // 修复:save 仅容错 ENOENT(run 已终态,状态不再变化,持久化无意义也无法完成)→ silent return;
187
+ // 非 ENOENT 错误(EACCES/ENOSPC 等真实磁盘问题)仍重新抛出,不掩盖。
188
+ //
189
+ // 注:真正的 ENOENT 只在 rmSync 与 mkdir 并发时出现(串行 rmSync 后 mkdir {recursive:true}
190
+ // 会重建目录而非抛 ENOENT),故用 mock 直接锁定 save 的容错判定逻辑,不依赖竞态时序复现。
191
+ describe("W3: JsonlRunStore.save 兜底容错(run 工作目录被并发清理时的竞态)", () => {
192
+ let tmpDir: string;
193
+ let store: JsonlRunStore;
194
+
195
+ beforeEach(() => {
196
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-store-enoent-"));
197
+ store = new JsonlRunStore({ sessionDir: tmpDir });
198
+ });
199
+
200
+ afterEach(() => {
201
+ fs.rmSync(tmpDir, { recursive: true, force: true });
202
+ vi.restoreAllMocks();
203
+ });
204
+
205
+ it("mkdir 抛 ENOENT(sessionDir 被并发清理)→ save 静默返回,不抛 unhandled rejection", async () => {
206
+ const run = makeRunWithDoneCall();
207
+ const spy = vi
208
+ .spyOn(fs.promises, "mkdir")
209
+ .mockRejectedValueOnce(
210
+ Object.assign(new Error("ENOENT: no such file or directory, mkdir"), {
211
+ code: "ENOENT",
212
+ }),
213
+ );
214
+ // run 已终态 + 工作目录消失 → save 放弃持久化,resolve undefined(不抛)
215
+ await expect(store.save(run)).resolves.toBeUndefined();
216
+ expect(spy).toHaveBeenCalled();
217
+ });
218
+
219
+ it("mkdir 抛非 ENOENT 错误(EACCES)→ save 重新抛出,不掩盖真实磁盘问题", async () => {
220
+ const run = makeRunWithDoneCall();
221
+ const spy = vi
222
+ .spyOn(fs.promises, "mkdir")
223
+ .mockRejectedValueOnce(
224
+ Object.assign(new Error("permission denied"), { code: "EACCES" }),
225
+ );
226
+ await expect(store.save(run)).rejects.toThrow("permission denied");
227
+ expect(spy).toHaveBeenCalled();
228
+ });
229
+ });
@@ -241,7 +241,26 @@ export class JsonlRunStore {
241
241
  */
242
242
  async save(run: WorkflowRun): Promise<void> {
243
243
  const filePath = this.filePathFor(run.runId);
244
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
244
+ // 兜底容错:run 工作目录(sessionDir)已被清理时,mkdir ENOENT,save 放弃。
245
+ // 竞态场景(review-fix-loop-e2e 等 runAndWait 测试):handleReturn 内
246
+ // run.transition("done") 同步改 status 后,runAndWait 轮询发现 done 并 resolve,
247
+ // 测试 afterEach 随即 rmSync 删除 sessionDir;此时本方法 in-flight 的 mkdir
248
+ // 遇到目录链已删除 → ENOENT({recursive:true} 在并发 rmSync 下仍可抛 ENOENT)。
249
+ // run 既已终态(状态不再变化),持久化无意义也无法完成 → silent return。
250
+ // 仅容错 ENOENT,重新抛出其他错误(EACCES/ENOSPC 等真实磁盘问题不掩盖)。
251
+ try {
252
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
253
+ } catch (err) {
254
+ if (
255
+ typeof err === "object" &&
256
+ err !== null &&
257
+ "code" in err &&
258
+ (err as { code: unknown }).code === "ENOENT"
259
+ ) {
260
+ return;
261
+ }
262
+ throw err;
263
+ }
245
264
  const snapshot = serializeRun(run);
246
265
  await fs.promises.writeFile(filePath, JSON.stringify(snapshot) + "\n", "utf8");
247
266
  if (this.pi) {