@baique/pi-web-sky 0.1.56 → 0.1.57

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.
Files changed (138) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +2 -2
  5. package/.next/required-server-files.js +3 -2
  6. package/.next/required-server-files.json +3 -2
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/[id]/bash-output/route.js +2 -2
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/agent/new/route.js +1 -1
  23. package/.next/server/app/api/agent/running/route.js +1 -1
  24. package/.next/server/app/api/app-update/route.js +1 -1
  25. package/.next/server/app/api/board-assets/[...path]/route.js +1 -1
  26. package/.next/server/app/api/board-assets/route.js +1 -1
  27. package/.next/server/app/api/boards/[id]/add-session/route.js +1 -1
  28. package/.next/server/app/api/boards/[id]/reconcile/route.js +1 -1
  29. package/.next/server/app/api/boards/[id]/route.js +1 -1
  30. package/.next/server/app/api/boards/reorder/route.js +1 -1
  31. package/.next/server/app/api/boards/route.js +1 -1
  32. package/.next/server/app/api/cwd/validate/route.js +1 -1
  33. package/.next/server/app/api/default-cwd/route.js +1 -1
  34. package/.next/server/app/api/file-index/route.js +1 -1
  35. package/.next/server/app/api/files/[...path]/route.js +2 -2
  36. package/.next/server/app/api/mcp/route.js +1 -1
  37. package/.next/server/app/api/models/route.js +1 -1
  38. package/.next/server/app/api/plugins/route.js +1 -1
  39. package/.next/server/app/api/project-trust/route.js +1 -1
  40. package/.next/server/app/api/search/route.js +2 -2
  41. package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
  42. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  43. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +2 -2
  44. package/.next/server/app/api/sessions/[id]/export/route.js +2 -2
  45. package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
  46. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  47. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  48. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  49. package/.next/server/app/api/sessions/route.js +1 -1
  50. package/.next/server/app/api/sessions/summary/route.js +1 -1
  51. package/.next/server/app/api/skills/install/route.js +1 -1
  52. package/.next/server/app/api/subagents/[id]/route.js +1 -1
  53. package/.next/server/app/api/subagents/profiles/route.js +1 -1
  54. package/.next/server/app/api/task-card-questions/[id]/answer/route.js +1 -1
  55. package/.next/server/app/api/task-card-questions/route.js +1 -1
  56. package/.next/server/app/api/task-cards/[id]/route.js +1 -1
  57. package/.next/server/app/api/task-cards/route.js +1 -1
  58. package/.next/server/app/api/task-cards/unbind/route.js +1 -1
  59. package/.next/server/app/api/task-scheduler/status/route.js +1 -1
  60. package/.next/server/app/api/tasks/[id]/assign-session/route.js +2 -12
  61. package/.next/server/app/api/tasks/[id]/board/route.js +1 -1
  62. package/.next/server/app/api/tasks/[id]/route.js +1 -1
  63. package/.next/server/app/api/tasks/reorder/route.js +2 -12
  64. package/.next/server/app/api/tasks/route.js +2 -12
  65. package/.next/server/app/api/worktrees/route.js +1 -1
  66. package/.next/server/app/index.html +1 -1
  67. package/.next/server/app/index.rsc +5 -5
  68. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  69. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  70. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/page.js +10 -10
  72. package/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app-paths-manifest.json +5 -5
  74. package/.next/server/chunks/1937.js +14 -13
  75. package/.next/server/chunks/2524.js +2 -2
  76. package/.next/server/chunks/3500.js +1 -1
  77. package/.next/server/chunks/402.js +21 -10
  78. package/.next/server/chunks/4457.js +3 -13
  79. package/.next/server/chunks/542.js +8 -8
  80. package/.next/server/chunks/5794.js +1 -1
  81. package/.next/server/chunks/5940.js +1 -1
  82. package/.next/server/chunks/6429.js +4 -4
  83. package/.next/server/chunks/6525.js +18 -7
  84. package/.next/server/chunks/6583.js +12 -11
  85. package/.next/server/middleware-build-manifest.js +1 -1
  86. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  87. package/.next/server/pages/404.html +1 -1
  88. package/.next/server/pages/500.html +1 -1
  89. package/.next/static/chunks/752.bafff304e6b00987.js +1 -0
  90. package/.next/static/chunks/8088.39866dc5f1d15868.js +1 -0
  91. package/.next/static/chunks/app/{layout-87087b955e0c9507.js → layout-fae2b226b3c78448.js} +1 -1
  92. package/.next/static/chunks/app/page-d5af3bd535589daf.js +256 -0
  93. package/.next/static/chunks/{webpack-4de8e8dd9f2f3173.js → webpack-1ce35e1249cfd931.js} +1 -1
  94. package/.next/static/css/{c6d32b6031181be7.css → c7d127e07b1a786c.css} +1 -1
  95. package/.next/trace +8 -7
  96. package/.next/trace-build +1 -1
  97. package/lib/board-reconcile.test.mjs +26 -8
  98. package/lib/board-reconcile.ts +3 -1
  99. package/lib/board-store.test.mjs +2 -2
  100. package/lib/ime-guard.test.mjs +52 -0
  101. package/lib/ime-guard.ts +54 -0
  102. package/lib/rpc-manager.activity.test.mjs +223 -0
  103. package/lib/rpc-manager.test.mjs +22 -0
  104. package/lib/rpc-manager.title.test.mjs +152 -0
  105. package/lib/rpc-manager.ts +95 -8
  106. package/lib/session-activity.test.mjs +123 -0
  107. package/lib/session-activity.ts +77 -0
  108. package/lib/session-delete.test.mjs +45 -41
  109. package/lib/session-delete.ts +8 -34
  110. package/lib/session-index-scanner.test.mjs +364 -2
  111. package/lib/session-index-scanner.ts +162 -57
  112. package/lib/session-reader.noscan.test.mjs +371 -0
  113. package/lib/session-reader.project.test.mjs +30 -52
  114. package/lib/session-reader.test.mjs +2 -65
  115. package/lib/session-reader.ts +177 -213
  116. package/lib/session-scanner.test.mjs +151 -38
  117. package/lib/session-scanner.ts +140 -61
  118. package/lib/session-search.ts +6 -5
  119. package/lib/sqlite-db.test.mjs +26 -0
  120. package/lib/sqlite-db.ts +10 -1
  121. package/lib/subagent-runtime.ts +42 -0
  122. package/lib/subagent-session-title.test.mjs +120 -0
  123. package/lib/task-scheduler.test.mjs +27 -0
  124. package/lib/task-scheduler.ts +8 -1
  125. package/lib/task-store.activity.test.mjs +141 -0
  126. package/lib/task-store.meta-row.test.mjs +25 -5
  127. package/lib/task-store.partial-row.test.mjs +198 -0
  128. package/lib/task-store.subtree.test.mjs +172 -0
  129. package/lib/task-store.test.mjs +46 -20
  130. package/lib/task-store.ts +344 -68
  131. package/next.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/.next/static/chunks/752.adce8bc3ebbaf4b6.js +0 -1
  134. package/.next/static/chunks/8088.0cad1ff2e09fd8ff.js +0 -1
  135. package/.next/static/chunks/app/page-efca6bf51a5f8ccb.js +0 -256
  136. /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_buildManifest.js +0 -0
  137. /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_ssgManifest.js +0 -0
  138. /package/lib/{subagent-isolation.test.mjs → worktree-isolation.test.mjs} +0 -0
@@ -21,9 +21,18 @@ import { resolveShellTools } from "./powershell-settings";
21
21
  import { CHAT_ONLY_RESOURCE_LOADER_OPTIONS } from "./chat-only";
22
22
  import { cacheSessionPath, invalidateSessionListCache, resolveSessionPath } from "./session-reader";
23
23
  import { indexSessionFileNow } from "./session-index-scanner";
24
+ import { createSessionActivityTracker, type AgentLikeMessage } from "./session-activity";
25
+ import { FIRST_MESSAGE_PREVIEW_LENGTH } from "./session-scanner";
24
26
  import { projectIdentityKey } from "./project-identity";
25
27
  import { resolveProject } from "./worktree";
26
- import { ensureSessionMetaRow } from "./task-store";
28
+ import {
29
+ ensureSessionMetaRow,
30
+ fillFirstMessageIfEmpty,
31
+ recordSessionOutcome,
32
+ setSessionTitle,
33
+ taskForSession,
34
+ touchSessionActivity,
35
+ } from "./task-store";
27
36
  import { reconcileForkBoard } from "./board-reconcile";
28
37
  import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust";
29
38
  import { persistExplicitStartupPreferences } from "./startup-preferences";
@@ -215,6 +224,8 @@ export class AgentSessionWrapper {
215
224
  private _alive = true;
216
225
  /** 最近一次 agent_start 的时间(看板“运行时长”用);0 = 未知 */
217
226
  private lastAgentStart = 0;
227
+ /** 会话活跃事件 → 落库(last_reply / modified);按 wrapper 实例持有防并发串台 */
228
+ private readonly activityTracker = createSessionActivityTracker();
218
229
  private readonly exactSystemPrompt?: () => string;
219
230
  private readonly chatOnly: boolean;
220
231
 
@@ -276,11 +287,42 @@ export class AgentSessionWrapper {
276
287
  invalidateSessionListCache();
277
288
  }
278
289
  if (IDLE_RESET_EVENT_TYPES.has(event.type)) this.resetIdleTimer();
290
+ // 会话活跃落库(last_reply / modified)。包在 try/catch 里:库是旁路索引,
291
+ // 写失败必须可见(console.error),但绝不能打断上面几件事与事件分发。
292
+ try {
293
+ this.persistSessionActivity(event);
294
+ } catch (error) {
295
+ console.error(
296
+ `[pi-web] 会话活跃写库失败(${event.type}):`,
297
+ error instanceof Error ? error.message : String(error),
298
+ );
299
+ }
279
300
  this.emit(event);
280
301
  });
281
302
  this.resetIdleTimer();
282
303
  }
283
304
 
305
+ /**
306
+ * 事件 → session_meta 写入(只 UPDATE;行由创建/扫描链路建立):
307
+ * · agent_start → modified 前移(运行中会话浮顶)
308
+ * · message_end → 本轮最后一条 assistant 文本(缓存,不落库)
309
+ * · agent_settled → last_reply + modified(一轮循环真正结束;用户取消走同一路径)
310
+ * 用 agent_settled 而非 agent_end:后者一轮里可能多次(重试/compaction/queue 续跑)。
311
+ */
312
+ private persistSessionActivity(event: AgentEvent): void {
313
+ const effect = this.activityTracker.handle(event as { type: string; message?: AgentLikeMessage });
314
+ if (!effect) return;
315
+ const sessionId = this.inner.sessionId;
316
+ if (!sessionId) return;
317
+ if (effect.kind === "touch") {
318
+ touchSessionActivity(sessionId);
319
+ return;
320
+ }
321
+ recordSessionOutcome(sessionId, effect);
322
+ // 首条用户消息此刻已在内存 entries 里(不读文件,读取路径零扫盘)。
323
+ fillFirstMessageIfEmpty(sessionId, firstUserMessageOf(this.inner.sessionManager.getEntries()));
324
+ }
325
+
284
326
  setForceEmptySystemPrompt(force: boolean): void {
285
327
  this.forceEmptySystemPrompt = force;
286
328
  this.applyForcedEmptySystemPrompt();
@@ -630,8 +672,10 @@ export class AgentSessionWrapper {
630
672
  const newSessionId = SessionManager.open(forkedPath, sessionDir).getSessionId();
631
673
  cacheSessionPath(newSessionId, forkedPath);
632
674
  invalidateSessionListCache();
633
- // 立即进 session_meta 索引(否则要等 30s 后台扫描才出现在会话列表)
634
- await indexSessionFileNow(forkedPath, sessionManager.getSessionId());
675
+ // 立即进 session_meta 索引(否则要等 30s 后台扫描才出现在会话列表);
676
+ // 归属继承:引用分支与源会话同任务(否则只会在聊天区当孤儿根出现)。
677
+ const sourceSessionId = sessionManager.getSessionId();
678
+ await indexSessionFileNow(forkedPath, sourceSessionId, taskForSession(sourceSessionId));
635
679
  return { cancelled: false, newSessionId };
636
680
  }
637
681
 
@@ -694,6 +738,7 @@ export class AgentSessionWrapper {
694
738
  // resolveProject 的 await 不能发生在 registry 还挂着已 fork wrapper 的窗口里;
695
739
  // 建行只用局部变量(cwd/newSessionFile/sourceSessionId),不依赖 wrapper。
696
740
  const cwd = sessionManager.getCwd();
741
+ const sourceTaskId = taskForSession(sourceSessionId); // 归属继承:fork 子会话与父同任务
697
742
  await this.shutdown();
698
743
  try {
699
744
  const project = await resolveProject(cwd ?? "");
@@ -702,13 +747,28 @@ export class AgentSessionWrapper {
702
747
  cwd: cwd ?? "",
703
748
  projectKey: projectIdentityKey(project?.projectRoot ?? cwd ?? ""),
704
749
  parentId: sourceSessionId,
750
+ taskId: sourceTaskId, // 建行即带归属,否则子会话先以临时会话落在聊天区(V1)
705
751
  });
752
+ } catch (error) {
753
+ // 不阻塞 fork(文件已经在了,pi 侧已成功),但必须可见:静默失败会让子会话
754
+ // 以「无归属临时会话」出现在聊天区(也是本次要修的 V1 病灶);扫描器的归属
755
+ // 收敛是第二道网,不能当成唯一防线。
756
+ console.error(
757
+ `[pi-web] fork 建行失败 session=${newSessionId}:`,
758
+ error instanceof Error ? error.message : String(error),
759
+ );
760
+ }
761
+ try {
706
762
  // fork 卡即时入板:源会话归属任务 → 精准 reconcile 该任务看板。
707
763
  // await 保证卡先落 yjs 再返回(前端响应到达时卡已存在,无并发写窗口);
708
- // 失败不阻塞 fork,10s 定时兜底。
764
+ // 失败不阻塞 fork,10s 定时兜底。单独一个 try:与建行失败分开报错,避免
765
+ // 入板失败被误报成「建行失败」。
709
766
  await reconcileForkBoard(sourceSessionId);
710
- } catch {
711
- // 建行失败不阻塞 fork:扫描器下一轮兜底补行。
767
+ } catch (error) {
768
+ console.error(
769
+ `[pi-web] fork 入板失败 source=${sourceSessionId}:`,
770
+ error instanceof Error ? error.message : String(error),
771
+ );
712
772
  }
713
773
  return { cancelled: false, newSessionId };
714
774
  }
@@ -748,6 +808,15 @@ export class AgentSessionWrapper {
748
808
  const name = (command.name as string | undefined)?.trim();
749
809
  if (!name) throw new Error("Session name cannot be empty");
750
810
  this.inner.setSessionName(name);
811
+ // 列表索引同步:pi 侧改名成功即写 session_meta.title(前端侧栏/看板卡
812
+ // 改名走这条 RPC,不落库则刷新后退回旧标题)。失败处理与路由层同源但
813
+ // 语义有别:这里只记日志、不抛——RPC 已经改了文件名,抛错对调用方没有
814
+ // 可恢复动作,还会断掉命令响应/事件流。
815
+ try {
816
+ await setSessionTitle(this.inner.sessionId, name);
817
+ } catch (error) {
818
+ console.error("[pi-web] 会话标题写库失败:", error);
819
+ }
751
820
  invalidateSessionListCache();
752
821
  return null;
753
822
  }
@@ -1576,8 +1645,13 @@ async function persistNewSessionFile(manager: SessionManager, sessionId: string)
1576
1645
  // 与扫描器同源归一化(Windows 大小写折叠等),防新建会话 project_key 与列表查询键不一致
1577
1646
  projectKey: projectIdentityKey(project?.projectRoot ?? cwd ?? ""),
1578
1647
  });
1579
- } catch {
1580
- // 建行失败不阻塞开会话:扫描器下一轮兜底补行。
1648
+ } catch (error) {
1649
+ // 建行失败不阻塞开会话(文件已落盘,运行时列表由 runtime union 覆盖),但必须可见:
1650
+ // 静默会让该会话最多 30s 不在列表/任务区(扫描器会补行,但不能当成唯一防线)。
1651
+ console.error(
1652
+ `[pi-web] 新会话建行失败 session=${sessionId}:`,
1653
+ error instanceof Error ? error.message : String(error),
1654
+ );
1581
1655
  }
1582
1656
  }
1583
1657
 
@@ -1595,6 +1669,19 @@ function runtimeMessageText(entry: SessionMessageEntry): string {
1595
1669
  .join(" ");
1596
1670
  }
1597
1671
 
1672
+ /**
1673
+ * 内存 entries 里首条用户消息的简述(截断与扫描器同一上限)。
1674
+ * 会话活跃事件落库时用它回填 first_message——读取路径不再读文件。
1675
+ */
1676
+ function firstUserMessageOf(entries: unknown[]): string {
1677
+ for (const entry of entries as SessionMessageEntry[]) {
1678
+ if (entry?.type !== "message" || entry.message?.role !== "user") continue;
1679
+ const text = runtimeMessageText(entry).trim();
1680
+ if (text) return text.slice(0, FIRST_MESSAGE_PREVIEW_LENGTH);
1681
+ }
1682
+ return "";
1683
+ }
1684
+
1598
1685
  function runtimeMessageActivityMs(entry: SessionMessageEntry): number | undefined {
1599
1686
  if (entry.message.role !== "user" && entry.message.role !== "assistant") return undefined;
1600
1687
  if (typeof entry.message.timestamp === "number") return entry.message.timestamp;
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createJiti } from "jiti";
4
+
5
+ const jiti = createJiti(import.meta.url, {
6
+ interopDefault: true,
7
+ moduleCache: false,
8
+ alias: { "@": process.cwd() },
9
+ });
10
+ const { createSessionActivityTracker, lastAssistantText } = await jiti.import("./session-activity.ts");
11
+
12
+ const assistant = (text, ts, stopReason = "stop") => ({
13
+ role: "assistant",
14
+ timestamp: ts,
15
+ stopReason,
16
+ content: [{ type: "text", text }],
17
+ });
18
+
19
+ /** 依次喂事件,返回所有非 null 效果(真实调用方的用法:一轮事件流按序处理)。 */
20
+ function run(events) {
21
+ const tracker = createSessionActivityTracker();
22
+ const effects = [];
23
+ for (const event of events) {
24
+ const effect = tracker.handle(event);
25
+ if (effect) effects.push(effect);
26
+ }
27
+ return effects;
28
+ }
29
+
30
+ test("agent_start → touch(只刷活跃时间,浮动到列表顶部)", () => {
31
+ assert.deepEqual(run([{ type: "agent_start" }]), [{ kind: "touch" }]);
32
+ });
33
+
34
+ test("单条 message_end 不落 outcome(不是循环最后一条)", () => {
35
+ assert.deepEqual(run([{ type: "message_end", message: assistant("中间回复", 1000) }]), []);
36
+ });
37
+
38
+ test("agent_settled → outcome(带最后一条 assistant 文本与时间戳)", () => {
39
+ const effects = run([
40
+ { type: "message_end", message: assistant("第一条", 1000) },
41
+ { type: "message_end", message: { role: "user", content: "再问" } },
42
+ { type: "message_end", message: assistant("最后一条回复", 2000) },
43
+ { type: "agent_settled" },
44
+ ]);
45
+ assert.deepEqual(effects, [{ kind: "outcome", lastReply: "最后一条回复", at: 2000 }]);
46
+ });
47
+
48
+ test("用户取消(stopReason=aborted)同样落 outcome", () => {
49
+ const effects = run([
50
+ { type: "message_end", message: assistant("被打断的半截回复", 3000, "aborted") },
51
+ { type: "agent_settled" },
52
+ ]);
53
+ assert.deepEqual(effects, [{ kind: "outcome", lastReply: "被打断的半截回复", at: 3000 }]);
54
+ });
55
+
56
+ test("本轮没采到任何文本 → 只 touch(空文本不清库)", () => {
57
+ // 刚发出就被取消 / 整轮只有工具调用:agent_settled 不得产出 lastReply:"",
58
+ // 否则调用方会把已存的最后一条回复清空。
59
+ assert.deepEqual(run([{ type: "agent_start" }, { type: "agent_settled" }]), [
60
+ { kind: "touch" },
61
+ { kind: "touch" },
62
+ ]);
63
+ assert.deepEqual(
64
+ run([
65
+ { type: "message_end", message: { role: "assistant", timestamp: 1, content: [{ type: "toolCall", name: "bash" }] } },
66
+ { type: "message_end", message: { role: "assistant", timestamp: 2, content: [{ type: "thinking", thinking: "想一想" }] } },
67
+ { type: "message_end", message: { role: "assistant", timestamp: 3, content: [{ type: "text", text: " " }] } },
68
+ { type: "agent_settled" },
69
+ ]),
70
+ [{ kind: "touch" }],
71
+ );
72
+ });
73
+
74
+ test("settle 后 pending 归零:第二段 settle(无新消息)只 touch", () => {
75
+ const tracker = createSessionActivityTracker();
76
+ tracker.handle({ type: "message_end", message: assistant("回复", 1000) });
77
+ assert.deepEqual(tracker.handle({ type: "agent_settled" }), {
78
+ kind: "outcome",
79
+ lastReply: "回复",
80
+ at: 1000,
81
+ });
82
+ assert.deepEqual(tracker.handle({ type: "agent_settled" }), { kind: "touch" });
83
+ });
84
+
85
+ test("tracker 按实例持有状态:并发会话不串台", () => {
86
+ const a = createSessionActivityTracker();
87
+ const b = createSessionActivityTracker();
88
+ a.handle({ type: "message_end", message: assistant("A 的回复", 1000) });
89
+ b.handle({ type: "message_end", message: assistant("B 的回复", 2000) });
90
+ assert.deepEqual(b.handle({ type: "agent_settled" }), {
91
+ kind: "outcome",
92
+ lastReply: "B 的回复",
93
+ at: 2000,
94
+ });
95
+ assert.deepEqual(a.handle({ type: "agent_settled" }), {
96
+ kind: "outcome",
97
+ lastReply: "A 的回复",
98
+ at: 1000,
99
+ });
100
+ });
101
+
102
+ test("无关事件(tool_execution_end 等)不产生效果,也不破坏 pending", () => {
103
+ const effects = run([
104
+ { type: "message_end", message: assistant("回复", 1000) },
105
+ { type: "tool_execution_start", toolName: "bash" },
106
+ { type: "queue_update", steering: [] },
107
+ { type: "agent_settled" },
108
+ ]);
109
+ assert.deepEqual(effects, [{ kind: "outcome", lastReply: "回复", at: 1000 }]);
110
+ });
111
+
112
+ test("lastAssistantText:只取 text 块,thinking/toolCall 忽略", () => {
113
+ assert.equal(
114
+ lastAssistantText({
115
+ role: "assistant",
116
+ content: [{ type: "thinking", thinking: "x" }, { type: "text", text: "a" }, { type: "text", text: "b" }],
117
+ }),
118
+ "a\nb",
119
+ );
120
+ assert.equal(lastAssistantText({ role: "assistant", content: "plain" }), "plain");
121
+ assert.equal(lastAssistantText({ role: "assistant" }), "");
122
+ assert.equal(lastAssistantText({ role: "assistant", content: [{ type: "text", text: " " }] }), "");
123
+ });
@@ -0,0 +1,77 @@
1
+ // ============================================================================
2
+ // 会话活跃事件 → 数据库写入意图(纯函数,无 IO)
3
+ //
4
+ // 背景:会话列表的「最后一条消息」「最后活跃时间」原先由 30s 扫描器从文件
5
+ // mtime 反推(滞后 ≤30s,且发消息不刷库)。改为事件驱动:一轮循环真正结束
6
+ // 时(agent_settled)把最后一条 assistant 文本与时间戳落库;用户取消走同一条
7
+ // 路径(SDK 会为取消生成 stopReason="aborted" 的 assistant 消息,随后同样
8
+ // 走 turn_end → agent_end → agent_settled)。
9
+ // · agent_start → touch(活跃时间立刻前移,运行中会话浮顶)
10
+ // · message_end(assistant) → 缓存本轮最后一条(不落库;一轮可能多条)
11
+ // · agent_settled → outcome(落 last_reply + modified)
12
+ // 两个边界:
13
+ // · agent_end 一轮里可能多次(重试/compaction/queue 续跑),所以不用它落库;
14
+ // message_end 也不是「循环结束」,单条不落库。
15
+ // · 本轮一条 assistant 文本都没采到(刚发出就被取消、整轮只有工具调用)时,
16
+ // agent_settled 退化为 touch —— **绝不用空文本覆盖已存的 last_reply**。
17
+ // 状态必须**按 wrapper 实例**持有:多个会话并发跑时共享模块级变量会串台。
18
+ // ============================================================================
19
+
20
+ /** 事件里透传的 message(SDK 的 AgentMessage 的最小结构约定)。 */
21
+ export interface AgentLikeMessage {
22
+ role?: string;
23
+ content?: unknown;
24
+ timestamp?: number;
25
+ stopReason?: string;
26
+ }
27
+
28
+ export type SessionActivityEffect =
29
+ | { kind: "touch" }
30
+ | { kind: "outcome"; lastReply: string; at: number };
31
+
32
+ /** assistant 消息的纯文本(text 块拼接;无 text 返回 "")。 */
33
+ export function lastAssistantText(message: AgentLikeMessage): string {
34
+ const content = message.content;
35
+ if (typeof content === "string") return content.trim();
36
+ if (!Array.isArray(content)) return "";
37
+ return content
38
+ .filter((block): block is { type?: string; text?: string } => Boolean(block) && typeof block === "object")
39
+ .filter((block) => block.type === "text" && typeof block.text === "string")
40
+ .map((block) => block.text as string)
41
+ .join("\n")
42
+ .trim();
43
+ }
44
+
45
+ /** 每个 AgentSessionWrapper 持有一个 tracker,内部缓存「本轮最后一条 assistant」。 */
46
+ export function createSessionActivityTracker(): {
47
+ handle(event: { type: string; message?: AgentLikeMessage }): SessionActivityEffect | null;
48
+ } {
49
+ let pending: { lastReply: string; at: number } | null = null;
50
+ return {
51
+ handle(event) {
52
+ if (event.type === "agent_start") return { kind: "touch" };
53
+ if (event.type === "message_end") {
54
+ const message = event.message;
55
+ if (message?.role === "assistant") {
56
+ const text = lastAssistantText(message);
57
+ // 空文本(纯工具轮 / 半截回复)不覆盖已缓存的文本:本轮最后一条有内容的
58
+ // assistant 消息才是「最后一条消息」。
59
+ if (text) {
60
+ pending = {
61
+ lastReply: text,
62
+ at: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
63
+ };
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ if (event.type === "agent_settled") {
69
+ const outcome = pending;
70
+ pending = null;
71
+ // 本轮没采到文本 → 只刷活跃时间,让调用方保留库里已有的 last_reply。
72
+ return outcome ? { kind: "outcome", ...outcome } : { kind: "touch" };
73
+ }
74
+ return null;
75
+ },
76
+ };
77
+ }
@@ -1,53 +1,57 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
2
  import test from "node:test";
6
3
  import { createJiti } from "jiti";
4
+ import { DatabaseSync } from "node:sqlite";
7
5
 
8
6
  const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
9
7
  const { collectSessionDescendants } = await jiti.import("./session-delete.ts");
10
- const { cacheSessionPath } = await jiti.import("./session-reader.ts");
11
-
12
- function sessionFile(dir, id, parentPath) {
13
- const p = join(dir, `${Date.now()}_${id}.jsonl`);
14
- const header = {
15
- type: "session",
16
- version: 3,
17
- id,
18
- timestamp: new Date().toISOString(),
19
- cwd: dir,
20
- ...(parentPath ? { parentSession: parentPath } : {}),
21
- };
22
- writeFileSync(p, JSON.stringify(header) + "\n");
23
- return p;
8
+ const { setDbForTesting, getDb } = await jiti.import("./sqlite-db.ts");
9
+
10
+ function freshDb() {
11
+ setDbForTesting(new DatabaseSync(":memory:"));
12
+ }
13
+
14
+ /** 建一行会话索引。collectSessionDescendants 只读 session_meta.parent_id:
15
+ * 盘上有没有文件、文件在哪个目录都不影响(旧实现靠 readdirSync 平铺目录 + 读每个
16
+ * 文件 header 推父链,所以那时必须有文件且必须同目录——见下面「跨目录」用例)。 */
17
+ function seed(id, parentId = null, path = `/nonexistent/${id}.jsonl`) {
18
+ getDb()
19
+ .prepare("INSERT INTO session_meta (session_id, task_id, updated, pinned, path, parent_id) VALUES (?, NULL, 1, 0, ?, ?)")
20
+ .run(id, path, parentId);
24
21
  }
25
22
 
26
- test("collectSessionDescendants walks the whole fork tree (multi-level)", async () => {
27
- const dir = mkdtempSync(join(tmpdir(), "pi-sd-"));
28
- try {
29
- const rootPath = sessionFile(dir, "root-id");
30
- const child1Path = sessionFile(dir, "child1", rootPath);
31
- sessionFile(dir, "child2", child1Path); // 两级分叉:child1 → child2
32
- sessionFile(dir, "unrelated", undefined); // 无 parentSession 的无关会话
33
- // 注入路径缓存,resolveSessionPath 命中后不触碰真实 sessions 目录。
34
- cacheSessionPath("root-id", rootPath);
35
-
36
- const ids = await collectSessionDescendants("root-id");
37
- assert.deepEqual(ids.sort(), ["root-id", "child1", "child2"].sort());
38
- assert.ok(!ids.includes("unrelated"));
39
- } finally {
40
- rmSync(dir, { recursive: true, force: true });
41
- }
23
+ test("collectSessionDescendants walks the whole fork tree by DB parent_id (multi-level)", async () => {
24
+ freshDb();
25
+ seed("root-id");
26
+ seed("child1", "root-id");
27
+ seed("child2", "child1"); // 两级分叉:child1 → child2
28
+ seed("unrelated");
29
+
30
+ const ids = await collectSessionDescendants("root-id");
31
+ assert.deepEqual(ids.sort(), ["root-id", "child1", "child2"].sort());
32
+ assert.ok(!ids.includes("unrelated"));
42
33
  });
43
34
 
44
35
  test("collectSessionDescendants returns the root alone when there are no forks", async () => {
45
- const dir = mkdtempSync(join(tmpdir(), "pi-sd-"));
46
- try {
47
- const rootPath = sessionFile(dir, "root-alone");
48
- cacheSessionPath("root-alone", rootPath);
49
- assert.deepEqual(await collectSessionDescendants("root-alone"), ["root-alone"]);
50
- } finally {
51
- rmSync(dir, { recursive: true, force: true });
52
- }
36
+ freshDb();
37
+ seed("root-alone");
38
+ assert.deepEqual(await collectSessionDescendants("root-alone"), ["root-alone"]);
39
+ });
40
+
41
+ test("collectSessionDescendants 跨目录:子目录里的 fork 也要一起删(旧实现只 readdir 平铺目录会漏)", async () => {
42
+ freshDb();
43
+ // 外部 pi 系工具的布局:`<proj>/<session>/forks/<ts>_<id>.jsonl`。库里 parent_id 已含
44
+ // 全部副本,所以父子的 path 在不同目录、甚至文件早就不在盘上,一样整棵返回。
45
+ seed("root", null, "/proj/2026-01-01T00-00-00Z_root.jsonl");
46
+ seed("fork1", "root", "/proj/2026-01-01T00-00-00Z_root/forks/2026-01-01T00-10-00-000Z_fork1.jsonl");
47
+ seed("fork2", "fork1", "/nonexistent/fork2.jsonl"); // 文件已不在盘上:库里仍要删
48
+
49
+ assert.deepEqual((await collectSessionDescendants("root")).sort(), ["fork1", "fork2", "root"]);
50
+ });
51
+
52
+ test("collectSessionDescendants 防环:脏 parent_id 环不会死循环", async () => {
53
+ freshDb();
54
+ seed("a", "b");
55
+ seed("b", "a");
56
+ assert.deepEqual((await collectSessionDescendants("a")).sort(), ["a", "b"]);
53
57
  });
@@ -3,50 +3,24 @@
3
3
  // 与单个会话删除(app/api/sessions/[id] DELETE)不同:那里删一个节点并把
4
4
  // 子树级联重挂到父级;这里整棵树全部删除,不做重挂。
5
5
 
6
- import { readdirSync, unlinkSync } from "fs";
7
- import { dirname, join } from "path";
6
+ import { unlinkSync } from "fs";
8
7
  import {
9
8
  invalidateSessionListCache,
10
9
  invalidateSessionPathCache,
11
- readSessionHeader,
12
10
  resolveSessionPath,
13
11
  } from "./session-reader";
14
- import { sessionPathKey } from "./session-path";
15
12
  import { getRpcSession } from "./rpc-manager";
16
- import { unassignSession } from "./task-store";
13
+ import { listDescendantIds, unassignSession } from "./task-store";
17
14
  import { removeSessionFromBoards } from "./board-store";
18
15
  import { removeSessionsFromYjsBoards } from "./board-reconcile";
19
16
 
20
- /** 递归收集一个会话的全部 fork 后代 id(含自身)。 */
17
+ /** 递归收集一个会话的全部 fork 后代 id(含自身)。
18
+ *
19
+ * 父子链以 session_meta.parent_id 为唯一事实源:旧实现 readdirSync 平铺会话目录
20
+ * + 读每个文件 header 推父链,既漏掉子目录里的 fork(`<session>/forks/…`),
21
+ * 也在每次删任务时把整个项目目录读一遍。库里的 parent_id 已含全部副本。 */
21
22
  export async function collectSessionDescendants(rootId: string): Promise<string[]> {
22
- const rootPath = await resolveSessionPath(rootId);
23
- if (!rootPath) return [rootId];
24
- const dir = dirname(rootPath);
25
- const keySet = new Set<string>([sessionPathKey(rootPath)]);
26
- const ids = [rootId];
27
- let files: string[];
28
- try {
29
- files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
30
- } catch {
31
- return [rootId];
32
- }
33
- // 迭代扫描:parentSession 指向已收集集合的 .jsonl 全部纳入(支持多级分叉)。
34
- let added = true;
35
- while (added) {
36
- added = false;
37
- for (const file of files) {
38
- const p = join(dir, file);
39
- const pk = sessionPathKey(p);
40
- if (keySet.has(pk)) continue;
41
- const header = readSessionHeader(p);
42
- if (header?.parentSession && keySet.has(sessionPathKey(header.parentSession))) {
43
- keySet.add(pk);
44
- ids.push(header.id);
45
- added = true;
46
- }
47
- }
48
- }
49
- return ids;
23
+ return [rootId, ...listDescendantIds(rootId)];
50
24
  }
51
25
 
52
26
  /** 删除单个会话文件(含 RPC/路径缓存/列表缓存/任务元数据/画布引用),不重挂子树。 */