Parcourir la source

feat(state): 会话级状态增加 roomId 退路匹配(thread_id 大量为 NULL)

## 问题
容器实测:`state.db` 里 Matrix 会话的 `thread_id` **大量为 NULL**(本月 46%),
`build_session_key()` 拼不出 thread 段,只能报 Hermes 内部 id(如 20261111_195708_eebcd2d8)。
App 拿 `$approot_xxx` 去匹配,**永远匹配不上** → 会话级状态形同虚设。

## 修法:三级匹配
① sessionKey 含 thread 根($approot_xxx)— 最精确,一房多会话不串
② sessionKey 含 roomId(!xxx)— 退路,DM 无 thread 时靠房间认
③ 都不中 → undefined,回落全局状态

依据:容器实测 `chat_id` **100% 稳定**(212 条全是 ! 开头,同房间跨 22 天、83 条会话不变)。

## 代价(已知并接受)
roomId 粒度比 thread 粗,同房间多条会话时「任一在跑」会被认为「这条在跑」。
相比「完全匹配不上、状态永远不显示」,这个误判可接受。

## 改动
- agent-state: sessionStateOf/sessionRunning 增加 roomIdOf 参数
- chat.service: 暴露 currentRoomId();agentIdle 传入
- task-pulse: describe 增加 roomId 参数
- session-tree: 传 s.roomId(ChatSession 自带)
- desktop chat: taskLine 传 currentRoomId()

新增 3 条测试(roomId 命中 / 不传保持原行为 / thread 优先于 roomId)。

## 验证
tsc ✅ · 1658 条测试全过 ✅ · 已部署(main-DWRLFJPQ.js)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct il y a 59 minutes
Parent
commit
302db8cbf4

+ 6 - 2
src/app/components/domain/session-tree/session-tree.ts

@@ -2503,8 +2503,12 @@ export class SessionTreeComponent implements OnDestroy {
   protected sessionRunningOf(s: ChatSession): boolean {
     const agent = this.liveSvc.agentById(s.agentId);
     if (!agent) return false;
-    // currentSessions 的 sessionKey 形如 agent:main:matrix:dm:<roomId>[:<threadId>]
-    return sessionRunning(agent.currentSessions, s.id) === true;
+    /*
+     * 传 `s.roomId` 作为退路:容器上报的 sessionKey 在 DM 通道下常只有 roomId
+     * (`thread_id` 为 NULL,实测 46%),光靠 thread 根匹配不上。
+     * 见 `agent-state.ts::sessionStateOf()` 的三级匹配。
+     */
+    return sessionRunning(agent.currentSessions, s.id, s.roomId) === true;
   }
 
   protected unreadOf(agentId: string): number {

+ 34 - 0
src/app/core/agent-state.spec.ts

@@ -304,4 +304,38 @@ describe('sessionStateOf / sessionRunning(会话级状态)', () => {
   it('没打开会话 → undefined', () => {
     expect(sessionRunning(SESSIONS, null)).toBeUndefined();
   });
+
+  /*
+   * 🔴 roomId 退路(2026-11-11 实测加了这条)。
+   *
+   * 容器实测:`state.db` 里 Matrix 会话的 `thread_id` 大量为 NULL(本月 46%),
+   * 于是 `build_session_key()` 拼不出 thread 段,只能退到 `chat_id`(roomId)。
+   * 而 `chat_id` 实测 100% 稳定(212 条全是 `!` 开头,同房间跨 22 天不变)。
+   *
+   * 所以匹配必须支持「sessionKey 里只有 roomId」的情况,否则会话级状态永远不显示。
+   */
+  describe('roomId 退路', () => {
+    const NO_THREAD = [
+      { sessionKey: 'agent:main:matrix:dm:!roomA', status: 'running' as const },
+      { sessionKey: 'agent:main:matrix:dm:!roomB', status: 'idle' as const },
+    ];
+
+    it('🔴 thread_id 为 NULL → 用 roomId 匹配上', () => {
+      expect(sessionRunning(NO_THREAD, APP_SID, '!roomA')).toBeTrue();
+      expect(sessionRunning(NO_THREAD, APP_SID, '!roomB')).toBeFalse();
+    });
+
+    it('不传 roomId → 保持原行为(只按 thread 匹配,匹配不到返回 undefined)', () => {
+      expect(sessionRunning(NO_THREAD, APP_SID)).toBeUndefined();
+    });
+
+    it('thread 精确命中优先于 roomId(一房多会话不串)', () => {
+      const mixed = [
+        { sessionKey: 'agent:main:matrix:dm:!roomA', status: 'idle' as const },
+        { sessionKey: `agent:main:matrix:dm:!roomA:${APP_SID}`, status: 'running' as const },
+      ];
+      // thread 命中 running 的那条,而不是 roomA 的 idle
+      expect(sessionStateOf(mixed, APP_SID, '!roomA')?.status).toBe('running');
+    });
+  });
 });

+ 39 - 8
src/app/core/agent-state.ts

@@ -330,25 +330,55 @@ export function agentLayers(agent: Agent | null | undefined, now = Date.now()):
  * 🔴 两边的标识格式不同,必须容错匹配:
  *
  * ```
- * App 会话 id      $approot_mw7l1ezj_wnuym1jt:fmode.cn     ← Matrix thread 根 event_id
- * 容器 sessionKey  agent:main:matrix:dm:!roomId:threadId    ← build_session_key()
+ * App 会话 id       $approot_mw7l1ezj_wnuym1jt:fmode.cn    ← Matrix thread 根 event_id
+ * 容器 sessionKey   agent:main:matrix:dm:!roomId:threadId   ← build_session_key()
  * ```
  *
- * 所以判据是「容器的 sessionKey 里**包含** App 的会话 id 主体」——
- * 容器把 thread_id 拼在 key 的末尾,而 thread_id 就是 App 造的根 event_id。
+ * ## 三级匹配(2026-11-11 实测定稿)
  *
- * 🔴 这条匹配是否成立**必须实测**(见方案文档「待确认」)。
- *    匹配不到就返回 undefined,调用方回落到全局状态,不显示「未知」。
+ * | 优先级 | 依据 | 说明 |
+ * |---|---|---|
+ * | ① | sessionKey 含 **thread 根**(`$approot_xxx`) | 最精确,一房多会话也不会串 |
+ * | ② | sessionKey 含 **roomId**(`!xxx`) | 退路:DM 通道 `thread_id` 常为 NULL(实测 46%),只能靠房间认 |
+ * | ③ | 都不中 | 返回 undefined,调用方回落全局状态 |
+ *
+ * ## 为什么需要 roomId 这条退路
+ *
+ * 实测(lj-test,2026-11-11):`state.db` 里 Matrix 会话的 `thread_id` **大量为 NULL**
+ * (本月 DM 通道进入时没有 `thread.relates_to`),于是容器只能报内部 id,
+ * App 永远匹配不上 → 会话级状态形同虚设。
+ *
+ * 而 `chat_id`(roomId)实测 **100% 稳定**:212 条会话全是 `!` 开头,
+ * 同一房间跨 22 天、83 条会话不变。
+ *
+ * ⚠️ 代价:roomId 粒度比 thread 粗。同一房间下多条会话时,
+ * 「有任意一条在跑」会被认为「这条在跑」。
+ * 但相比「完全匹配不上、状态永远不显示」,这个误判是可接受的取舍。
+ *
+ * @param roomIdOf 可选:把 App 会话 id 换算成 roomId(调用方从会话目录查)。
+ *                 不传则只用 thread 根匹配(向后兼容)。
  */
 export function sessionStateOf(
   sessions: AgentSessionState[] | undefined,
   appSessionId: string | null | undefined,
+  roomIdOf?: string | null,
 ): AgentSessionState | undefined {
   if (!sessions?.length || !appSessionId) return undefined;
   // 去掉 App 会话 id 的域名后缀,只留主体($approot_xxx)
   const core = appSessionId.split(':')[0];
   if (!core) return undefined;
-  return sessions.find((s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId));
+
+  /* ① 精确:thread 根命中 */
+  const byThread = sessions.find(
+    (s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId),
+  );
+  if (byThread) return byThread;
+
+  /* ② 退路:roomId 命中(DM 无 thread 的场景) */
+  if (roomIdOf) {
+    return sessions.find((s) => s.sessionKey.includes(roomIdOf));
+  }
+  return undefined;
 }
 
 /**
@@ -360,7 +390,8 @@ export function sessionStateOf(
 export function sessionRunning(
   sessions: AgentSessionState[] | undefined,
   appSessionId: string | null | undefined,
+  roomIdOf?: string | null,
 ): boolean | undefined {
-  const s = sessionStateOf(sessions, appSessionId);
+  const s = sessionStateOf(sessions, appSessionId, roomIdOf);
   return s ? s.status === 'running' : undefined;
 }

+ 9 - 1
src/app/core/chat.service.ts

@@ -264,7 +264,7 @@ export class ChatService {
      * 会话 B 也会被告知「不空闲」→ 回合永远收不了尾(用户实测)。
      * 容器按会话上报时以它为准;没上报(旧容器)才回落到全局 status。
      */
-    const mine = sessionRunning(agent.currentSessions, this._sessionId());
+    const mine = sessionRunning(agent.currentSessions, this._sessionId(), this.roomId);
     if (mine !== undefined) return !mine;
     return agent.status === 'idle';
   });
@@ -302,6 +302,14 @@ export class ChatService {
   private provision: MatrixProvision | null = null;
   /** 真实态下的当前房间 id */
   private roomId: string | null = null;
+  /**
+   * 当前房间 id 的只读视图(2026-11-11)。
+   *
+   * 供会话级状态匹配用:容器上报的 `sessionKey` 可能只含 roomId
+   * (DM 通道 `thread_id` 常为 NULL,实测 46%),此时需要用房间号对齐。
+   * 见 `agent-state.ts::sessionStateOf()` 的三级匹配。
+   */
+  readonly currentRoomId = (): string | null => this.roomId;
   /** 事件订阅解绑函数(切会话必须调用,防串扰) */
   private unbindMessage: (() => void) | null = null;
   private unbindTyping: (() => void) | null = null;

+ 9 - 4
src/app/core/task-pulse.service.ts

@@ -195,14 +195,19 @@ export class TaskPulseService {
   }
 
   /** 「当前任务」的一句话文案(唯一实现,列表/头部/右栏都用它) */
-  describe(agent: Agent | undefined, now = Date.now(), sessionId?: string | null): TaskLine {
+  describe(agent: Agent | undefined, now = Date.now(), sessionId?: string | null, roomId?: string | null): TaskLine {
     // 依赖这些信号,让调用方在数据更新后重算
     this._localStart();
     this.live.tick();
-    return this.describeOf(agent, now, sessionId);
+    return this.describeOf(agent, now, sessionId, roomId);
   }
 
-  private describeOf(agent: Agent | undefined, now: number, sessionId?: string | null): TaskLine {
+  private describeOf(
+    agent: Agent | undefined,
+    now: number,
+    sessionId?: string | null,
+    roomId?: string | null,
+  ): TaskLine {
     if (!agent) return { text: '', tone: 'neutral' };
     const local = this._localStart().get(agent.id);
     /*
@@ -264,7 +269,7 @@ export class TaskPulseService {
      * 此时回落到下面的全局口径,行为与改造前完全一致。
      */
     const hasSessionReport = (agent.currentSessions?.length ?? 0) > 0;
-    const mine = sessionAware ? sessionRunning(agent.currentSessions, sessionId) : undefined;
+    const mine = sessionAware ? sessionRunning(agent.currentSessions, sessionId, roomId) : undefined;
     if (sessionAware && hasSessionReport && mine !== true) {
       /*
        * 本会话没在跑 —— 两种情况都归到这里:

+ 6 - 1
src/app/desktop/pages/chat/chat.ts

@@ -2187,7 +2187,12 @@ export class DkChatPage implements OnDestroy {
      * 容器同一时刻可能在跑别的会话,拿全局 status 会显示别人的任务。
      * 本会话没在跑时返回「在线」,而不是容器全局的「已完成 · 用时 …」。
      */
-    return this.pulse.describe(this.live.agentById(agentId), Date.now(), this.chat.sessionId());
+    return this.pulse.describe(
+      this.live.agentById(agentId),
+      Date.now(),
+      this.chat.sessionId(),
+      this.chat.currentRoomId(),
+    );
   }
 
   /** 列表行紧凑标签:一次只答一个问题 —— 在不在干活 */