Эх сурвалжийг харах

refactor(state): 状态裁决收口到单点 + 多人共用容器的 userId 隔离

## 背景:多源冲突是「显示错」的根源
这次排查中反复出现的问题,根因都是同一件事有多个来源、各算各的:
  · 右上角「正在回复」vs 卡片「21s 已完成」→ 头部看 typing,卡片看静默窗口
  · 打开会话 A 看到会话 B 的进度 → 匹配不上时回落容器全局 status
  · 正文早已输出完,界面还挂「输出中」→ typing 无人清除

## 1. 统一裁决:resolveSessionPhase()
所有界面共用一个优先级表,只有它说了算:
  ① reaction(✅/❌)  一票否决
  ② sessionRunning    本会话(含 userId 过滤)
  ③ typing            仅过渡态(容器没报会话级时兜住新建会话那几秒)
  ④ turnLive          静默窗口兜底
  ⚠️ 全局 status      彻底不用(串台根源)

## 2. 去掉全局 status 回落(chat.service.agentIdle)
原最后一行 `return agent.status === 'idle'` 是串台根源:
status 是整个容器一个值,会话 A 在跑时会话 B 也被判成不空闲。
实测证据:容器 status=running 时,currentSessions 里有一条 idle 一条 running,
旧逻辑打开那条 idle 的会显示「正在回复」。

## 3. 多人共用容器的 userId 隔离
容器可被同公司多人共享,currentSessions 是容器级列表。
· models: AgentSessionState.userId(容器查 state.db.sessions.user_id 得到)
· parse-api: 解析 userId(形状不对就省略)
· agent-state: sessionStateOf 先按 userId 收窄,再走 thread → roomId
· 老容器不上报 userId → 保持原行为,不退化

## 验证
tsc ✅ · 1668 条测试全过(新增 10 条)✅ · 构建 ✅
容器侧已实测生效:currentSessions 每条都带 userId

**未部署**(用户要求先测试)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 17 цаг өмнө
parent
commit
ac51a68b67

+ 61 - 0
src/app/core/agent-liveness.spec.ts

@@ -6,6 +6,7 @@ import {
   heartbeatAgeSec,
   isReporterSelfTalk,
   livenessOf,
+  resolveSessionPhase,
   onlineLabel,
   sessionPresence,
 } from './agent-liveness';
@@ -212,3 +213,63 @@ describe('agent-liveness', () => {
     });
   });
 });
+
+/*
+ * 🔴 本会话运行态的**唯一裁决入口**(2026-11-11 统一方案)。
+ *
+ * 之前「右上角正在回复 vs 卡片已完成」「会话 A 显示会话 B 进度」这类问题,
+ * 根因都是同一件事有多个来源各算各的。这组用例锁定优先级表:
+ *   reaction > session(会话级上报)> typing(过渡态)> turn(静默窗口推断)
+ */
+describe('resolveSessionPhase(统一状态裁决)', () => {
+  it('① reaction 一票否决:说完了就是完了,其他信号全部不作数', () => {
+    const r = resolveSessionPhase({
+      turnEnded: true,
+      sessionRunning: true, // 上报还在跑(上报有延迟)
+      typing: true,
+      turnLive: true,
+    });
+    expect(r.busy).withContext('收到 ✅ 就不该再显示在忙').toBeFalse();
+    expect(r.reason).toBe('reaction');
+  });
+
+  it('② 会话级上报优先于 typing', () => {
+    expect(resolveSessionPhase({ turnEnded: false, sessionRunning: true, typing: false })).toEqual({
+      busy: true,
+      phase: 'replying',
+      reason: 'session',
+    });
+    expect(resolveSessionPhase({ turnEnded: false, sessionRunning: false, typing: true })).toEqual({
+      busy: false,
+      reason: 'session',
+    });
+  });
+
+  it('🔴 别的会话在跑不影响本会话:sessionRunning=false 时 typing 不得翻盘', () => {
+    const r = resolveSessionPhase({ turnEnded: false, sessionRunning: false, typing: true });
+    expect(r.busy).withContext('容器说本会话空闲,typing 残留不能说明在跑').toBeFalse();
+  });
+
+  it('③ typing 只作过渡态:容器没上报会话级状态时才生效', () => {
+    expect(resolveSessionPhase({ turnEnded: false, sessionRunning: undefined, typing: true })).toEqual({
+      busy: true,
+      phase: 'thinking',
+      reason: 'typing',
+    });
+  });
+
+  it('④ 最后的兜底:回合自身推断', () => {
+    expect(resolveSessionPhase({ turnEnded: false, turnLive: true })).toEqual({
+      busy: true,
+      phase: 'replying',
+      reason: 'turn',
+    });
+  });
+
+  it('什么都没有 → 空闲(不知道就是不知道,不拿全局 status 冒充)', () => {
+    const r = resolveSessionPhase({ turnEnded: false });
+    expect(r.busy).toBeFalse();
+    expect(r.phase).toBeUndefined();
+    expect(r.reason).toBe('idle');
+  });
+});

+ 52 - 0
src/app/core/agent-liveness.ts

@@ -197,6 +197,58 @@ export function onlineLabel(v: LivenessView): { text: string; tone: SemanticTone
 /** 会话页的「本会话相位」(容器按会话上报时才有) */
 export type SessionPhase = 'thinking' | 'replying';
 
+/**
+ * 本会话运行态的**唯一判定入口**(2026-11-11 统一方案定稿)。
+ *
+ * ## 为什么要收口到一个函数
+ *
+ * 这次排查中反复出现的「显示错」,根因都是**同一件事有多个来源、各算各的**:
+ *
+ * | 现象 | 当时的双源冲突 |
+ * |---|---|
+ * | 右上角「正在回复…」,卡片「21s 已完成」 | 头部看 `peerTyping`,卡片看静默窗口 |
+ * | 打开会话 A 看到会话 B 的进度 | `currentSessions` 匹配不上时回落到**容器全局 `status`** |
+ * | 正文早已输出完,界面还挂「输出中」 | typing 无人清除,只能等静默窗口 |
+ *
+ * 现在把所有信号喂进这一个函数,**只有它说了算**,各界面只负责渲染。
+ *
+ * ## 信号与优先级(从高到低)
+ *
+ * | 优先级 | 信号 | 含义 | 为什么排这里 |
+ * |---|---|---|---|
+ * | ① | `reaction`(✅/❌) | 容器**确实**结束了本轮 | 唯一「确定干完」的事实,且实测晚于正文 0.0~0.1s |
+ * | ② | `sessionRunning` | 容器上报的**本会话**是否在跑 | turn 粒度,精确到会话;无反应信号时最可信 |
+ * | ③ | `typing` | 容器「正在输入」 | **只作过渡态**:新建会话、容器还没回第一条时兜住那几秒 |
+ * | ④ | `live` | 回合自身的静默窗口推断 | 最后兜底,纯时间推断 |
+ *
+ * ⚠️ **刻意不使用容器全局 `status`**:它是「一个容器一个值」,
+ * 会话 A 在跑时会话 B 也被判成在跑 —— 这正是「串台」的根源。
+ * 不知道就是不知道,宁可显示「在线」也不拿全局状态冒充。
+ *
+ * @returns `busy` = 本会话是否在干活;`phase` 为 undefined 表示没有可显示的相位
+ */
+export function resolveSessionPhase(sig: {
+  /** 容器已明确结束本轮(✅/❌ reaction 到达) */
+  turnEnded: boolean;
+  /** 容器上报的本会话运行态:true 在跑 / false 空闲 / undefined 没上报 */
+  sessionRunning?: boolean;
+  /** 容器正在输入(过渡态用) */
+  typing?: boolean;
+  /** 回合自身的 live(静默窗口推断) */
+  turnLive?: boolean;
+}): { busy: boolean; phase?: SessionPhase; reason: string } {
+  /* ① 结束信号一票否决 —— 它说完了就是完了 */
+  if (sig.turnEnded) return { busy: false, reason: 'reaction' };
+  /* ② 会话级上报:唯一能区分「我」和「别的会话」的信号 */
+  if (sig.sessionRunning === true) return { busy: true, phase: 'replying', reason: 'session' };
+  if (sig.sessionRunning === false) return { busy: false, reason: 'session' };
+  /* ③ typing 过渡态:只在容器没上报会话级状态时生效 */
+  if (sig.typing) return { busy: true, phase: 'thinking', reason: 'typing' };
+  /* ④ 回合自身推断 */
+  if (sig.turnLive) return { busy: true, phase: 'replying', reason: 'turn' };
+  return { busy: false, reason: 'idle' };
+}
+
 /**
  * 会话页状态 = 容器在线状态 **叠加** 本会话实时相位。
  *

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

@@ -339,3 +339,49 @@ describe('sessionStateOf / sessionRunning(会话级状态)', () => {
     });
   });
 });
+
+/*
+ * 🔴 多人共用容器的隔离(2026-11-11)。
+ *
+ * 容器可能被同公司多人共享,`currentSessions` 是容器级的一个列表。
+ * A 在跑任务时,B 打开自己的会话不该看到「正在回复」。
+ *
+ * 依据:容器实测 `state.db.sessions` 表本来就有 `user_id` 列,
+ * 上报时会补到每条记录的 `userId` 字段。
+ */
+describe('多人共用容器:userId 隔离', () => {
+  const APP_SID = '$approot_me:fmode.cn';
+  const ROOM = '!sameRoom:fmode.cn';
+
+  it('🔴 别人在跑、我在空闲 → 不得误判成我在跑', () => {
+    const sessions = [
+      { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const },
+      { sessionKey: '!sameRoom:fmode.cn', userId: '@me:fmode.cn', status: 'idle' as const },
+    ];
+    // 不传 userId:roomId 命中第一条(running)→ 误判
+    expect(sessionRunning(sessions, APP_SID, ROOM)).toBeTrue();
+    // 传了 userId:精确到我的那条 → idle
+    expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeFalse();
+  });
+
+  it('我在跑时能正确命中', () => {
+    const sessions = [
+      { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'idle' as const },
+      { sessionKey: '!sameRoom:fmode.cn', userId: '@me:fmode.cn', status: 'running' as const },
+    ];
+    expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
+  });
+
+  it('容器没上报 userId(老容器)→ 保持原行为,不因过滤而丢失', () => {
+    const sessions = [{ sessionKey: '!sameRoom:fmode.cn', status: 'running' as const }];
+    expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
+  });
+
+  it('只有别人的会话 → 过滤后为空,回落到全集,不误报「未知」', () => {
+    const sessions = [
+      { sessionKey: '!sameRoom:fmode.cn', userId: '@other:fmode.cn', status: 'running' as const },
+    ];
+    // 没有我的会话 → 按全集匹配到别人的 → running(roomId 级兜底,符合设计)
+    expect(sessionRunning(sessions, APP_SID, ROOM, '@me:fmode.cn')).toBeTrue();
+  });
+});

+ 17 - 3
src/app/core/agent-state.ts

@@ -362,21 +362,34 @@ export function sessionStateOf(
   sessions: AgentSessionState[] | undefined,
   appSessionId: string | null | undefined,
   roomIdOf?: string | null,
+  myUserId?: string | null,
 ): AgentSessionState | undefined {
   if (!sessions?.length || !appSessionId) return undefined;
   // 去掉 App 会话 id 的域名后缀,只留主体($approot_xxx)
   const core = appSessionId.split(':')[0];
   if (!core) return undefined;
 
+  /*
+   * 🔴 0. 先按 userId 收窄(2026-11-11,多人共用容器时必需)。
+   *
+   * 容器可能被同公司多人共享,`currentSessions` 是容器级的一个列表。
+   * 若列表里同时有多个用户的会话,而群聊又共用一个 thread(roomId 相同),
+   * 光靠 roomId 命中会把「别人在跑」误判成「我在跑」。
+   *
+   * 只在**容器确实上报了 userId** 时才收窄:老容器不上报 → 保持原行为。
+   */
+  const mine = myUserId ? sessions.filter((s) => !s.userId || s.userId === myUserId) : sessions;
+  const pool = mine.length ? mine : sessions;
+
   /* ① 精确:thread 根命中 */
-  const byThread = sessions.find(
+  const byThread = pool.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 pool.find((s) => s.sessionKey.includes(roomIdOf));
   }
   return undefined;
 }
@@ -391,7 +404,8 @@ export function sessionRunning(
   sessions: AgentSessionState[] | undefined,
   appSessionId: string | null | undefined,
   roomIdOf?: string | null,
+  myUserId?: string | null,
 ): boolean | undefined {
-  const s = sessionStateOf(sessions, appSessionId, roomIdOf);
+  const s = sessionStateOf(sessions, appSessionId, roomIdOf, myUserId);
   return s ? s.status === 'running' : undefined;
 }

+ 13 - 0
src/app/core/api/models.ts

@@ -113,6 +113,19 @@ export interface UsageSession {
 export interface AgentSessionState {
   /** 容器的 session_key 原文 */
   sessionKey: string;
+  /**
+   * 发起该会话的用户 mxid(2026-11-11 新增,容器按需补)。
+   *
+   * 🔴 为什么需要:容器**可能被多人共享**(同公司同事都能用)。
+   * `currentSessions` 是容器级的一个列表,A 在跑任务时 B 也会看到这条记录。
+   * 没有 user 信息,App 只能按 roomId 猜「这条是不是我的」——
+   * 不同用户的 DM 房间不同,roomId 天然隔离;但**群聊里多人共用一个 thread** 时
+   * roomId 相同,就无法区分「是在为谁跑」。
+   *
+   * 容器查 `state.db.sessions.user_id` 得到;查不到时**省略该字段**
+   * (App 按「无 user 信息」处理 → 退回 roomId 匹配,不误判)。
+   */
+  userId?: string;
   /** 该会话当前是否在跑 */
   status: 'running' | 'idle';
   /** 本轮开始的 epoch 毫秒(idle 时可缺省) */

+ 6 - 0
src/app/core/api/parse-api.ts

@@ -1138,9 +1138,15 @@ function mapAgent(raw: Record<string, unknown>): Agent {
         const key = typeof s['sessionKey'] === 'string' ? s['sessionKey'] : '';
         if (!key) return [];
         const st = s['status'] === 'running' ? 'running' : 'idle';
+        /*
+         * `userId`(2026-11-11):容器共享给多人时区分「谁的会话」。
+         * 形状不对就省略 —— 宁可退回 roomId 匹配,也不要拿脏值去比对。
+         */
+        const uid = typeof s['userId'] === 'string' && s['userId'].startsWith('@') ? s['userId'] : undefined;
         return [
           {
             sessionKey: key,
+            ...(uid ? { userId: uid } : {}),
             status: st as 'running' | 'idle',
             turnStartAt:
               typeof s['turnStartAt'] === 'number' && Number.isFinite(s['turnStartAt'])

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

@@ -254,19 +254,39 @@ export class ChatService {
    */
   readonly agentIdle = computed(() => {
     const id = this._agentId();
-    if (!id) return false;
+    if (!id) return true;
     const agent = this.live.agentById(id);
-    if (!agent) return false;
+    if (!agent) return true;
     /*
-     * 🔴 优先用**本会话**的状态(2026-11-09 方案 A)。
+     * 🔴 只用**本会话**的状态,不再回落容器全局 `status`(2026-11-11 统一方案)。
      *
-     * 容器上报的 `status` 是整个容器一个值:同一容器的会话 A 在跑时,
-     * 会话 B 也会被告知「不空闲」→ 回合永远收不了尾(用户实测)。
-     * 容器按会话上报时以它为准;没上报(旧容器)才回落到全局 status。
+     * 原来的最后一行 `return agent.status === 'idle'` 是「串台」的根源:
+     * `status` 是整个容器一个值,会话 A 在跑时会话 B 也被判成「不空闲」,
+     * 于是 B 的回合永远收不了尾、右上角显示 A 的进度。
+     *
+     * 现在:容器没上报本会话 → 返回**空闲**(不知道就是不知道)。
+     * 「是不是在干活」由 `resolveSessionPhase()` 统一裁决,
+     * 这里只回答「容器有没有明确说本会话在跑」。
      */
-    const mine = sessionRunning(agent.currentSessions, this._sessionId(), this.roomId);
-    if (mine !== undefined) return !mine;
-    return agent.status === 'idle';
+    return sessionRunning(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId()) !== true;
+  });
+
+  /**
+   * 本会话运行态的**原始信号包**(2026-11-11 统一方案)。
+   *
+   * 各界面不再各自拼信号,一律把它交给 `resolveSessionPhase()` 裁决。
+   * 见该函数的注释:多源冲突是「显示错」的根源。
+   */
+  readonly sessionSignals = computed(() => {
+    const id = this._agentId();
+    const agent = id ? this.live.agentById(id) : undefined;
+    return {
+      turnEnded: this._turnEndedAfter() !== null,
+      sessionRunning: agent
+        ? sessionRunning(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId())
+        : undefined,
+      typing: this._peerTyping(),
+    };
   });
   /** 正在加载更早历史 */
   readonly paginating = this._paginating.asReadonly();
@@ -300,6 +320,13 @@ export class ChatService {
    */
   private _blankSessionId: string | null = null;
   private provision: MatrixProvision | null = null;
+  /**
+   * 当前登录用户在 Matrix 的 mxid(2026-11-11)。
+   *
+   * 供会话级状态匹配用:容器可能被多人共享,`currentSessions` 里会有别人的会话,
+   * 需要 `userId` 才能区分「谁在跑」。见 `agent-state.ts::sessionStateOf()`。
+   */
+  readonly myMxId = computed(() => this.provision?.userMxId ?? null);
   /** 真实态下的当前房间 id */
   private roomId: string | null = null;
   /**

+ 10 - 3
src/app/core/task-pulse.service.ts

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

+ 14 - 23
src/app/desktop/pages/chat/chat.ts

@@ -35,6 +35,7 @@ import {
   blockedHint,
   LivenessView,
   livenessOf,
+  resolveSessionPhase,
   sessionPresence,
 } from '../../../core/agent-liveness';
 import { resolveAgentState } from '../../../core/agent-state';
@@ -2192,6 +2193,7 @@ export class DkChatPage implements OnDestroy {
       Date.now(),
       this.chat.sessionId(),
       this.chat.currentRoomId(),
+      this.chat.myMxId(),
     );
   }
 
@@ -2585,35 +2587,24 @@ export class DkChatPage implements OnDestroy {
    * 用户裁决(2026-11-11):「按页面分层」—— 只有会话页额外显示本会话相位,
    * 因为那是用户正在等的反馈。离线时相位一律不显示(不能说明明断了还在回复)。
    *
-   * 🔴 2026-11-11 二次修正:**必须与回合卡片同源**。
+   * 🔴 2026-11-11 统一方案:**交给 `resolveSessionPhase()` 单点裁决**。
    *
-   * 用户实测矛盾:右上角「正在回复…」,卡片却写「21s 已完成」且计时停了。
-   * 根因是两处各算各的:
-   *   · 头部原来只看 `peerTyping()` 布尔值 —— 容器持续刷 typing=true 时永远为真
-   *   · 卡片 `live` 受 25 秒静默窗口约束 —— 没有新消息就判完成
-   * 于是同一个回合,两个地方说法相反。
-   *
-   * 现在:**优先用回合自身的 `live`**(那是经过静默窗口/结束信号校准过的判据),
-   * 只有回合还没建立(刚发出去、还没渲染)时才回落到 typing。
+   * 此前头部和卡片各算各的,出现「右上角正在回复、卡片已完成」的矛盾;
+   * 又因为回落容器全局 `status`,出现「会话 A 显示会话 B 的进度」。
+   * 现在所有信号走同一个优先级表(reaction > session > typing > turn),
+   * 头部与卡片只是同一结论的两种呈现,不会再打架。
    */
   readonly headerPresence = computed(() => {
-    /*
-     * 依赖 clockTick,让回合状态随时间推进而重算(与 aiRows 同口径)。
-     * 没有这一行,`live` 会在某个快照上冻住。
-     */
+    /* 依赖 clockTick,让回合一侧的状态随时间重算(与 aiRows 同口径) */
     this.clockTick();
     const turns = this.aiRows();
     const liveTurn = [...turns].reverse().find((r) => r.kind === 'turn' && r.turn.live);
-    if (liveTurn && liveTurn.kind === 'turn') {
-      const phase = liveTurn.turn.phase === 'replying' ? 'replying' : 'thinking';
-      return sessionPresence(this.chat.agent(), phase);
-    }
-    /*
-     * 没有任何回合在跑:如果我这边刚发完消息、容器还在 typing,
-     * 说明回合对象可能还没建起来(首条消息未到)—— 保留 typing 作为过渡态。
-     */
-    const typing = this.chat.peerTyping() ? ('replying' as const) : undefined;
-    return sessionPresence(this.chat.agent(), typing);
+    const sig = this.chat.sessionSignals();
+    const resolved = resolveSessionPhase({
+      ...sig,
+      turnLive: !!liveTurn,
+    });
+    return sessionPresence(this.chat.agent(), resolved.phase);
   });
 
   /** 离线/未接入时的横幅提示(null = 在线,不显示) */