You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by "ARE404 (via GitHub)" <gi...@apache.org> on 2026/08/22 13:14:46 UTC

[PR] feat(desktop): add conservative WorkHub session router [maka]

ARE404 opened a new pull request, #3497:
URL: https://github.com/apache/maka/pull/3497

   ## Summary
   
   Adds the first usable WorkHub MVP as a unified entry point over existing ordinary Sessions.
   
   - Keeps Session as the sole source of truth. A Work is a user-facing view of one ordinary Session; WorkHub owns no second database, event log, or lifecycle.
   - Projects existing Sessions into a unified WorkHub conversation and routes executable input with strategy `R2.3-session-authoritative-hybrid`.
   - Supports direct routing, clarification between plausible Sessions, explicit creation, conservative creation for clear unmatched executable goals, and correction through the UI.
   - Keeps discussion and creation-deliberation in WorkHub instead of mutating Session state.
   - Adds a rollout preference, focused controller/policy tests, and the WorkHub domain-language note.
   
   Refs #3492
   
   ### Routing choices and experiments
   
   We evaluated all policies against the same 10-Session snapshot and Chinese acceptance guide. The scores describe different test layers and are not directly interchangeable:
   
   | Policy | Main choice | Same-snapshot result |
   | --- | --- | --- |
   | R1 | Aggressive routing with broad contextual carry-over | 10/11 |
   | R2 | Conservative routing with more clarification | 9/11 routing; 8/11 strict E2E |
   | R2.1 | Evidence-scoring policy tuned to the idealized cases | 11/11 idealized |
   | R2.2 | Unified-conversation/projection-first policy | 5/11 |
   | R2.3 | Session-authoritative hybrid adopted here | 8/11 routing/interaction; 7/11 strict E2E |
   
   R2.3 is not selected because it has the highest raw score. It is the best current compromise under the product constraints: no new authority, conservative behavior when evidence is ambiguous, and a usable path for creating new work.
   
   Its precedence is: exact full Session name; explicit creation/correction; core entity evidence; then bounded focus confirmed inside the current WorkHub lifetime. Weak multi-match input asks for clarification. Clear unmatched executable input creates an ordinary Session, while discussion, negated creation, and creation questions remain in WorkHub. At equal evidence, the latest correction wins.
   
   Adversarial review also hardened the implementation so archived Sessions remain inspectable but cannot be routed to, creation negation/questions cannot write, same-frame double submissions are gated synchronously, failed/waiting submissions preserve the draft, and routing only consumes evidence populated by the production Session adapter.
   
   ## Verification
   
   - `npm run lint` — passed (2,557 files)
   - `npm run format:check` — passed
   - `npm run typecheck` — passed
   - `npm run build` — passed
   - Desktop test suite — 1,184/1,184 passed
   - Focused WorkHub tests — 36/36 passed
   - `npx knip --workspace apps/desktop` — passed
   - `npx knip --workspace packages/ui` — passed
   - `git diff --check` — passed
   
   User-visible screenshot/recording is still pending: the local macOS session was locked during capture, so both UI-control and isolated Electron capture could not acquire a window. This is why the PR remains a draft.
   
   ## Follow-up scope
   
   - [ ] Initialize bounded recent/previous focus when WorkHub first opens.
   - [ ] Recognize first-time natural-language corrections without relying on the correction menu.
   - [ ] Reconstruct the aggregate WorkHub timeline after navigating into a Session and back.
   - [ ] Delegate ordinary stop/cancel actions; current stop delegation is correction-only.
   - [ ] Add a WorkHub screenshot or short recording after the macOS session is unlocked.
   
   ## AI use
   
   Select exactly one:
   
   - [ ] No generative tool made a substantive contribution
   - [x] Generative tooling made a substantive contribution
   
   Tool(s) and scope: Codex implemented the WorkHub MVP, produced routing experiments and tests, and performed parallel Standards/Spec adversarial review. The affected commit includes a `Generated-by: Codex` trailer.
   
   ## Checklist
   
   - [x] Tests cover the change and fail without it
   - [x] Lint, format, typecheck and the affected suites pass locally
   
   Does this PR entail a change in behavior?
   
   - [x] Yes — described under Summary above
   - [ ] No
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


Re: [PR] feat(desktop): add conservative WorkHub session router [maka]

Posted by "Astro-Han (via GitHub)" <gi...@apache.org>.
Astro-Han commented on code in PR #3497:
URL: https://github.com/apache/maka/pull/3497#discussion_r3837885056


##########
apps/desktop/src/renderer/workhub-route-policy.ts:
##########
@@ -0,0 +1,368 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type {
+  WorkHubSessionFacts,
+  WorkHubSessionTarget,
+} from './workhub-controller.js';
+
+export type WorkHubRouteEvidence =
+  | 'explicit_target'
+  | 'exact_session_name'
+  | 'route_correction'
+  | 'core_entity'
+  | 'recent_focus';
+
+export type WorkHubRouteDecision =
+  | {
+      kind: 'target';
+      target: WorkHubSessionTarget;
+      evidence: WorkHubRouteEvidence;
+    }
+  | {
+      kind: 'clarification';
+      options: WorkHubSessionFacts[];
+    }
+  | { kind: 'discussion' }
+  | { kind: 'new_session' };
+
+export interface WorkHubRoutePolicy {
+  resolve(input: {
+    text: string;
+    sessions: WorkHubSessionFacts[];
+    originPromptBySessionId: ReadonlyMap<string, string | undefined>;
+    explicitTarget?: WorkHubSessionTarget;
+  }): WorkHubRouteDecision;
+  rememberTarget(target: WorkHubSessionTarget): void;
+  rememberCorrection(text: string, target: WorkHubSessionTarget): void;
+}
+
+export function workHubNewSessionName(text: string): string {
+  const explicitChinese = text.match(
+    /(?:标题|名称|名字)(?:为|叫|是|:|:)\s*[“”"']?([^,,。;;\n“”"']{2,48})/u,
+  )?.[1]?.trim();
+  const explicitEnglish = text.match(
+    /\b(?:called|named|titled)\s+[“”"']?([^,,。;;.!?\n“”"']{2,48})/iu,
+  )?.[1]?.trim();
+  const explicit = explicitChinese ?? explicitEnglish;
+  if (explicit) return explicit;
+  const withoutCreationPrefix = text.trim().replace(
+    /^(?:(?:请|帮我|麻烦)?(?:创建|新建|开一个|新开)(?:一个)?(?:全新的?|新的?)?(?:普通)?\s*(?:Session|会话|工作|任务)?|(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:create|start|open)\s+(?:a\s+)?(?:(?:brand[- ]new|new)\s+)?(?:ordinary\s+)?(?:session|work|task))(?:\s+(?:called|named|titled))?[,,::\s-]*/iu,
+    '',
+  );
+  const firstClause = withoutCreationPrefix.split(/[,。;;\n]/u)[0]?.trim();
+  return firstClause?.slice(0, 48) || '新工作';
+}
+
+interface RouteCorrection {
+  text: string;
+  target: WorkHubSessionTarget;
+  sequence: number;
+}
+
+const MAX_ROUTE_CORRECTIONS = 32;
+const MIN_EXACT_SESSION_NAME_LENGTH = 2;
+const MIN_CORRECTION_TERM_LENGTH = 3;
+// One four-character Han phrase is usually a meaningful entity rather than
+// grammar; Latin needs either two whole-word matches or one distinctive word.
+const MIN_STRONG_HAN_MATCH_LENGTH = 4;
+const MIN_STRONG_LATIN_MATCH_COUNT = 2;
+const MIN_STRONG_SINGLE_LATIN_LENGTH = 8;
+const MAX_UNCERTAINTY_OPTIONS = 5;
+const MAX_RELATED_CLARIFICATION_OPTIONS = 4;
+
+/**
+ * Deep routing module for R2.3.
+ *
+ * It owns only transient inference context. Session identity, transcript,
+ * execution state, and recovery continue to come from the Session port.
+ */
+export function createWorkHubRoutePolicy(): WorkHubRoutePolicy {
+  let currentFocus: WorkHubSessionTarget | undefined;
+  let previousFocus: WorkHubSessionTarget | undefined;
+  const corrections: RouteCorrection[] = [];
+  let correctionSequence = 0;
+
+  return {
+    resolve({ text, sessions, originPromptBySessionId, explicitTarget }) {
+      if (explicitTarget) {
+        return { kind: 'target', target: explicitTarget, evidence: 'explicit_target' };
+      }
+
+      if (looksLikeExplicitNewSession(text)) {
+        return { kind: 'new_session' };
+      }
+
+      const normalizedInput = normalizeIdentityText(text);
+      const exact = sessions.map((session) => {
+        const sessionName = normalizeIdentityText(session.sessionName);
+        const qualifiedName = normalizeIdentityText(
+          `${session.projectName}/${session.sessionName}`,
+        );
+        return {
+          session,
+          matchLength: normalizedInput.includes(qualifiedName)
+            ? qualifiedName.length
+            : normalizedInput.includes(sessionName)
+              ? sessionName.length
+              : 0,
+        };
+      }).filter(({ matchLength }) => matchLength >= MIN_EXACT_SESSION_NAME_LENGTH)

Review Comment:
   **[P1] A two-character Latin session name matches as a substring here, before any whole-word guard can run.**
   
   `normalizeIdentityText` lowercases and strips *all* whitespace and punctuation (`:234-236`), so matching happens against one concatenated string. With `MIN_EXACT_SESSION_NAME_LENGTH = 2` (`:80`), a session named `AI` matches inside `repair the parser` — `rep**ai**r` — and this branch returns `evidence: 'exact_session_name'` and routes there. `UI` inside `build`, and similar pairs, behave the same way.
   
   **Reproduced against the controller at this head:** with a single ordinary session named `AI` and no explicit target, submitting `repair the parser` returns `target=ai, evidence=exact_session_name` and delivers to that unrelated session.
   
   **The new guards cannot help, because they are downstream of this return.** `rankRelatedSessions` — with `MIN_STRONG_LATIN_MATCH_COUNT` and the whole-word Latin work this PR adds — is only reached on the line after this block. The unique-match sort above only disambiguates *between* several substring hits; it does not question whether a hit was meaningful. And two-character names are genuinely supported input: `workHubNewSessionName` accepts `{2,48}` (`:56-70`).
   
   So the earlier English-routing `[P1]` is only **partly** closed. Everything else in it is fixed; this one path bypasses the fixes.
   
   **Direction:** Latin exact-identity matching should respect token or phrase boundaries, the way the new ranking already does. A very short name should not become an exact match silently — without explicit target syntax it is far more likely to be a coincidence inside an ordinary English sentence than a deliberate address.
   



##########
apps/desktop/src/renderer/workhub-controller.ts:
##########
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * WorkHub is a projection and routing surface over ordinary Sessions.
+ * Session and Runtime remain authoritative for transcript, execution, state,
+ * permissions, interactions, and recovery.
+ */
+
+import {
+  createWorkHubRoutePolicy,
+  type WorkHubRouteEvidence,
+  workHubNewSessionName,
+} from './workhub-route-policy.js';
+
+export interface WorkHubSessionTarget {
+  sessionId: string;
+}
+
+export type WorkHubSessionState =
+  | 'active'
+  | 'running'
+  | 'waiting_for_user'
+  | 'blocked'
+  | 'aborted';
+
+export interface WorkHubSessionFacts {
+  target: WorkHubSessionTarget;
+  projectName: string;
+  sessionName: string;
+  kind: 'ordinary' | 'internal' | 'subagent';
+  archived: boolean;
+  state: WorkHubSessionState;
+  latestResult?: string;
+  updatedAt: number;
+}
+
+export type WorkHubSessionSummary = Omit<WorkHubSessionFacts, 'kind'>;
+
+export interface WorkHubProjection {
+  sessions: WorkHubSessionSummary[];
+}
+
+export interface WorkHubSubmitInput {
+  requestId: string;
+  text: string;
+  explicitTarget?: WorkHubSessionTarget;
+  correction?: { from: WorkHubSessionTarget };
+}
+
+export const WORKHUB_ROUTING_STRATEGY_ID = 'wh-r2.3-session-core-evidence' as const;
+export type WorkHubRoutingStrategyId = typeof WORKHUB_ROUTING_STRATEGY_ID;
+
+export type WorkHubSubmission = (
+  | {
+      kind: 'submitted';
+      requestId: string;
+      target: WorkHubSessionTarget;
+      turnId: string;
+      evidence: WorkHubRouteEvidence | 'new_session';
+      correctedFrom?: WorkHubSessionTarget;
+    }
+  | {
+      kind: 'clarification';
+      requestId: string;
+      text: string;
+      options: Array<Pick<WorkHubSessionSummary, 'target' | 'projectName' | 'sessionName'>>;
+    }
+  | {
+      kind: 'discussion';
+      requestId: string;
+      text: string;
+    }
+  | {
+      kind: 'waiting';
+      requestId: string;
+      text: string;
+      target: WorkHubSessionTarget;
+    }
+) & { strategyId: WorkHubRoutingStrategyId };
+
+/**
+ * Internal seam. The renderer bridge is the production adapter; interface
+ * tests use an in-memory adapter.
+ */
+export interface WorkHubSessionPort {
+  list(): Promise<WorkHubSessionFacts[]>;
+  /**
+   * Returns rebuildable routing evidence read from the authoritative Session
+   * log. Implementations must not persist a second writable copy of it.
+   */
+  routingEvidence(
+    targets: readonly WorkHubSessionTarget[],
+  ): Promise<Array<{ target: WorkHubSessionTarget; originPrompt?: string }>>;
+  create(input: { name: string }): Promise<WorkHubSessionFacts>;
+  submit(target: WorkHubSessionTarget, text: string): Promise<{ turnId: string }>;
+  stop(target: WorkHubSessionTarget): Promise<void>;
+  subscribe(handler: () => void): () => void;
+}
+
+export interface WorkHubController {
+  read(): Promise<WorkHubProjection>;
+  submit(input: WorkHubSubmitInput): Promise<WorkHubSubmission>;
+  subscribe(handler: () => void): () => void;
+}
+
+export function createWorkHubController(deps: {
+  sessions: WorkHubSessionPort;
+}): WorkHubController {
+  const routePolicy = createWorkHubRoutePolicy();
+  return {
+    subscribe(handler) {
+      return deps.sessions.subscribe(handler);
+    },
+    async read() {
+      const facts = await deps.sessions.list();
+      return {
+        sessions: facts
+          .filter((session) => session.kind === 'ordinary')
+          .sort((left, right) => right.updatedAt - left.updatedAt)
+          .map(({ kind: _kind, ...session }) => session),
+      };
+    },
+    async submit(input) {
+      const sessions = await deps.sessions.list();
+      const ordinary = sessions.filter((session) => session.kind === 'ordinary');
+      // Archived Sessions remain visible as historical work, but Runtime Host
+      // rejects new root Turns for them. Never offer one as a routing target.
+      const routable = ordinary.filter((session) => !session.archived);
+      const routingEvidence = input.explicitTarget
+        ? []
+        : await deps.sessions.routingEvidence(routable.map((session) => session.target));
+      const decision = routePolicy.resolve({
+        text: input.text,
+        sessions: routable,
+        originPromptBySessionId: new Map(
+          routingEvidence.map((entry) => [entry.target.sessionId, entry.originPrompt]),
+        ),
+        ...(input.explicitTarget ? { explicitTarget: input.explicitTarget } : {}),
+      });
+      if (decision.kind === 'clarification') {
+        return {
+          kind: 'clarification',
+          strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+          requestId: input.requestId,
+          text: input.text,
+          options: decision.options.map((session) => ({
+            target: session.target,
+            projectName: session.projectName,
+            sessionName: session.sessionName,
+          })),
+        };
+      }
+      if (decision.kind === 'discussion') {
+        return {
+          kind: 'discussion',
+          strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+          requestId: input.requestId,
+          text: input.text,
+        };
+      }
+      let target: WorkHubSessionTarget;
+      let evidence: Extract<WorkHubSubmission, { kind: 'submitted' }>['evidence'];
+      if (decision.kind === 'new_session') {
+        const created = await deps.sessions.create({ name: workHubNewSessionName(input.text) });
+        if (created.kind !== 'ordinary') {
+          throw new Error('WorkHub can only create ordinary Sessions');
+        }
+        target = created.target;
+        evidence = 'new_session';
+      } else {
+        target = decision.target;
+        evidence = input.correction ? 'route_correction' : decision.evidence;
+      }
+      const targetSession = routable.find(
+        (session) => session.target.sessionId === target.sessionId,
+      );
+      if (!targetSession && evidence !== 'new_session') {
+        throw new Error('WorkHub target Session is unavailable');
+      }
+      if (targetSession?.state === 'waiting_for_user') {
+        return {
+          kind: 'waiting',
+          strategyId: WORKHUB_ROUTING_STRATEGY_ID,
+          requestId: input.requestId,
+          text: input.text,
+          target,
+        };
+      }
+      if (input.correction) {
+        await deps.sessions.stop(input.correction.from);

Review Comment:
   **[P1] Correcting a WorkHub route stops the target session's entire current root turn, including work that has nothing to do with this WorkHub message.**
   
   This `stop` is unconditional, and `WorkHubSessionPort.stop` calls `deps.sessions.stop(sessionId, { source: 'stop_button' })` — session-scoped, cancelling the whole current root turn (`root-turn-coordinator.ts:898-945`), not the message that was just misrouted.
   
   **Reproduced:** session A running, B idle. WorkHub sends `requested-1` to A; the bridge reports the delivery as `steered: true`. The user then corrects to B and sends `requested-2`. The actual call sequence is `send(a) → stop-root(a) → send(b)`, so A's pre-existing turn — and any tool side effects in flight — are cancelled.
   
   **The information needed to avoid this is available and then dropped.** `bridge-contract.d.ts:641-650` returns a delivery disposition, but `WorkHubSessionPort.submit` (`workhub-session-port.ts:112-120`) keeps only `{ turnId: result.turnId }` and discards it. By the time the correction runs, this code can no longer tell whether the earlier message created a root turn of its own or was steered into one that already existed — so it takes the destructive branch in both cases.
   
   **Existing guards do not cover it:** the `waiting_for_user` check above is narrower, the surface gate only prevents duplicate actions within one frame, and routing to a running session is intentional behaviour.
   
   This one is not a regression from the four new commits — it is present on the earlier head too, and the earlier review missed it.
   
   **Direction:** preserve the delivery disposition and ownership through the port, and stop only a root turn that *this* WorkHub submission created. For a message that was queued or steered, either retract it precisely or disable correction once it has been consumed, rather than cancelling the surrounding turn.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


Re: [PR] feat(desktop): add conservative WorkHub session router [maka]

Posted by "Astro-Han (via GitHub)" <gi...@apache.org>.
Astro-Han commented on code in PR #3497:
URL: https://github.com/apache/maka/pull/3497#discussion_r3836294566


##########
apps/desktop/src/renderer/workhub-route-policy.ts:
##########
@@ -0,0 +1,309 @@
+import type {
+  WorkHubSessionFacts,
+  WorkHubSessionTarget,
+} from './workhub-controller.js';
+
+export type WorkHubRouteEvidence =
+  | 'explicit_target'
+  | 'exact_session_name'
+  | 'route_correction'
+  | 'core_entity'
+  | 'recent_focus';
+
+export type WorkHubRouteDecision =
+  | {
+      kind: 'target';
+      target: WorkHubSessionTarget;
+      evidence: WorkHubRouteEvidence;
+    }
+  | {
+      kind: 'clarification';
+      options: WorkHubSessionFacts[];
+    }
+  | { kind: 'discussion' }
+  | { kind: 'new_session' };
+
+export interface WorkHubRoutePolicy {
+  resolve(input: {
+    text: string;
+    sessions: WorkHubSessionFacts[];
+    originPromptBySessionId: ReadonlyMap<string, string | undefined>;
+    explicitTarget?: WorkHubSessionTarget;
+  }): WorkHubRouteDecision;
+  rememberTarget(target: WorkHubSessionTarget): void;
+  rememberCorrection(text: string, target: WorkHubSessionTarget): void;
+}
+
+export function workHubNewSessionName(text: string): string {
+  const explicit = text.match(
+    /(?:标题|名称|名字)(?:为|叫|是|:|:)\s*[“”"']?([^,,。;;\n“”"']{2,48})/u,
+  )?.[1]?.trim();
+  if (explicit) return explicit;
+  const withoutCreationPrefix = text.trim().replace(
+    /^(?:请|帮我|麻烦)?(?:创建|新建|开一个|新开)(?:一个)?(?:全新的?|新的?)?(?:普通)?\s*(?:Session|会话|工作|任务)?[,,::\s]*/iu,
+    '',
+  );
+  const firstClause = withoutCreationPrefix.split(/[,。;;\n]/u)[0]?.trim();
+  return firstClause?.slice(0, 48) || '新工作';
+}
+
+interface RouteCorrection {
+  text: string;
+  target: WorkHubSessionTarget;
+  sequence: number;
+}
+
+const MAX_RECENT_TARGETS = 8;
+const MAX_ROUTE_CORRECTIONS = 32;
+
+/**
+ * Deep routing module for R2.3.
+ *
+ * It owns only transient inference context. Session identity, transcript,
+ * execution state, and recovery continue to come from the Session port.
+ */
+export function createWorkHubRoutePolicy(): WorkHubRoutePolicy {
+  const recentTargets: WorkHubSessionTarget[] = [];
+  const corrections: RouteCorrection[] = [];
+  let correctionSequence = 0;
+
+  return {
+    resolve({ text, sessions, originPromptBySessionId, explicitTarget }) {
+      if (explicitTarget) {
+        return { kind: 'target', target: explicitTarget, evidence: 'explicit_target' };
+      }
+
+      if (looksLikeExplicitNewSession(text)) {
+        return { kind: 'new_session' };
+      }
+
+      const normalizedInput = normalizeIdentityText(text);
+      const exact = sessions.map((session) => {
+        const sessionName = normalizeIdentityText(session.sessionName);
+        const qualifiedName = normalizeIdentityText(
+          `${session.projectName}/${session.sessionName}`,
+        );
+        return {
+          session,
+          matchLength: normalizedInput.includes(qualifiedName)
+            ? qualifiedName.length
+            : normalizedInput.includes(sessionName)
+              ? sessionName.length
+              : 0,
+        };
+      }).filter(({ matchLength }) => matchLength >= 2)
+        .sort((left, right) => right.matchLength - left.matchLength);
+      if (exact[0] && exact[0].matchLength > (exact[1]?.matchLength ?? 0)) {
+        return {
+          kind: 'target',
+          target: exact[0].session.target,
+          evidence: 'exact_session_name',
+        };
+      }
+
+      const related = rankRelatedSessions(text, sessions, originPromptBySessionId);
+      if (looksLikeTargetUncertainty(text) && sessions.length > 0) {
+        const relatedIds = new Set(related.map(({ session }) => session.target.sessionId));
+        const options = [
+          ...related.map(({ session }) => session),
+          ...sessions
+            .filter((session) => !relatedIds.has(session.target.sessionId))
+            .sort((left, right) => right.updatedAt - left.updatedAt),
+        ];
+        return { kind: 'clarification', options: options.slice(0, 5) };
+      }
+
+      const corrected = correctedTarget(text, sessions, corrections);
+      if (corrected) {
+        return { kind: 'target', target: corrected, evidence: 'route_correction' };
+      }
+
+      const previousReference = looksLikePreviousFocus(text);
+      const currentReference = !previousReference && looksLikeRecentFocus(text);
+      const focused = previousReference
+        ? recentTargets[1]
+        : currentReference
+          ? recentTargets[0]
+          : undefined;
+      const strongEvidenceElsewhere = focused
+        ? related.some(({ session, longestMatch }) =>
+          session.target.sessionId !== focused.sessionId && longestMatch >= 4)
+        : false;
+      const ambiguousEvidence = related.length > 1;
+      if (
+        focused &&
+        (previousReference || (!strongEvidenceElsewhere && !ambiguousEvidence))
+      ) {
+        return { kind: 'target', target: focused, evidence: 'recent_focus' };
+      }
+
+      if (
+        related[0] &&
+        related[0].longestMatch >= 4 &&
+        (related[1]?.longestMatch ?? 0) < 4
+      ) {
+        return {
+          kind: 'target',
+          target: related[0].session.target,
+          evidence: 'core_entity',
+        };
+      }
+
+      const weakNewTopic = related.length === 1 &&
+        related[0]!.longestMatch < 4 &&
+        looksExecutable(text) &&
+        !currentReference;
+      if (related.length > 0 && !weakNewTopic) {
+        return {
+          kind: 'clarification',
+          options: related.slice(0, 4).map(({ session }) => session),
+        };
+      }
+      return looksExecutable(text) ? { kind: 'new_session' } : { kind: 'discussion' };
+    },
+    rememberTarget(target) {
+      const next = [
+        target,
+        ...recentTargets.filter((candidate) => candidate.sessionId !== target.sessionId),
+      ];
+      recentTargets.splice(0, recentTargets.length, ...next.slice(0, MAX_RECENT_TARGETS));
+    },
+    rememberCorrection(text, target) {
+      correctionSequence += 1;
+      corrections.unshift({ text, target, sequence: correctionSequence });
+      corrections.splice(MAX_ROUTE_CORRECTIONS);
+    },
+  };
+}
+
+function correctedTarget(
+  text: string,
+  sessions: WorkHubSessionFacts[],
+  corrections: readonly RouteCorrection[],
+): WorkHubSessionTarget | undefined {
+  const queryTerms = new Set(routingTerms(text));
+  if (queryTerms.size === 0) return undefined;
+  const available = new Set(sessions.map((session) => session.target.sessionId));
+  const ranked = corrections
+    .filter((correction) => available.has(correction.target.sessionId))
+    .map((correction) => {
+      const matches = routingTerms(correction.text).filter((term) => queryTerms.has(term));
+      return {
+        correction,
+        score: matches.reduce((total, term) => total + term.length, 0),
+        longestMatch: matches.reduce((longest, term) => Math.max(longest, term.length), 0),
+      };
+    })
+    .filter(({ longestMatch }) => longestMatch >= 3)
+    .sort((left, right) =>
+      right.score - left.score || right.correction.sequence - left.correction.sequence);
+  return ranked[0]?.correction.target;
+}
+
+function normalizeIdentityText(value: string): string {
+  return value.toLocaleLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
+}
+
+function looksLikeRecentFocus(value: string): boolean {
+  return /(?:它|这个(?:问题|工作|任务)?|这项(?:工作|任务)|刚才(?:那个|的)?|继续|接着|\bit\b|continue)/iu.test(
+    value,
+  );
+}
+
+function looksLikePreviousFocus(value: string): boolean {
+  return /(?:上一个|前一个|之前那个|回到.{0,6}(?:之前|上一个|前一个)|previous\s+(?:one|work)|go\s+back)/iu.test(
+    value,
+  );
+}
+
+function looksLikeTargetUncertainty(value: string): boolean {

Review Comment:
   **[P1] Several routing signals have no English path, and the ones that do are tuned on Chinese.**
   
   The description says the policies were evaluated `against the same 10-Session snapshot and Chinese acceptance guide`. That shows through in the implementation in ways that leave English input on a different code path rather than a slightly worse one:
   
   - **`looksLikeTargetUncertainty` is Chinese-only.** There is no English alternative in the pattern at all. An English user typing *"I'm not sure which one this belongs to"* never reaches the clarification branch — it falls through to correction/focus/entity matching and gets routed somewhere with no prompt. This is the one signal whose entire job is to stop the router from guessing, and English input cannot trigger it.
   - **The boilerplate stripping in `routingTerms` is Chinese-only** (`只分析风险和测试点`, `不要修改任何文件`, `先不动代码`). The English equivalents — "just analyse the risk", "don't change any files", "don't touch the code yet" — stay in the term set and score against session identities.
   - **`ROUTING_STOP_TERMS` is 18 Chinese terms and 4 English ones** (`please`, `continue`, `work`, `task`). Common English routing noise — `the`, `this`, `file`, `issue`, `check`, `update`, `fix`, `code`, `test` — is absent, so those words all contribute to `score` and to `longestMatch`.
   - **`workHubNewSessionName` only extracts a title from Chinese markers** (`标题|名称|名字`), and only strips Chinese creation prefixes. `create a new session called Parser Cleanup` produces a session named from the first 48 characters of the whole sentence rather than `Parser Cleanup` — even though `looksLikeExplicitNewSession` *does* have an English branch, so English users reach this function and then get the degraded name.
   - **`looksExecutable`'s design-question veto includes `\bwhat\b`**, so *"fix what is broken in the parser"* is classified as a design question and routed to discussion instead of being executable.
   
   This is ordinary-user-path reachable, and the failure is silent: the message goes to the wrong Session, or to discussion, with no signal that routing was uncertain.
   
   We are not asking for full parity in this PR — an MVP is allowed to be narrow. What we would ask for is that the narrowness be *declared* rather than latent. Either scope the feature explicitly (the preference could state that routing is currently tuned for Chinese input), or add the English counterpart to `looksLikeTargetUncertainty` specifically, since that one is the safety valve: when the router cannot tell, asking is always the correct fallback, and right now English users cannot reach it.



##########
apps/desktop/src/renderer/workhub-route-policy.ts:
##########
@@ -0,0 +1,309 @@
+import type {
+  WorkHubSessionFacts,
+  WorkHubSessionTarget,
+} from './workhub-controller.js';
+
+export type WorkHubRouteEvidence =
+  | 'explicit_target'
+  | 'exact_session_name'
+  | 'route_correction'
+  | 'core_entity'
+  | 'recent_focus';
+
+export type WorkHubRouteDecision =
+  | {
+      kind: 'target';
+      target: WorkHubSessionTarget;
+      evidence: WorkHubRouteEvidence;
+    }
+  | {
+      kind: 'clarification';
+      options: WorkHubSessionFacts[];
+    }
+  | { kind: 'discussion' }
+  | { kind: 'new_session' };
+
+export interface WorkHubRoutePolicy {
+  resolve(input: {
+    text: string;
+    sessions: WorkHubSessionFacts[];
+    originPromptBySessionId: ReadonlyMap<string, string | undefined>;
+    explicitTarget?: WorkHubSessionTarget;
+  }): WorkHubRouteDecision;
+  rememberTarget(target: WorkHubSessionTarget): void;
+  rememberCorrection(text: string, target: WorkHubSessionTarget): void;
+}
+
+export function workHubNewSessionName(text: string): string {
+  const explicit = text.match(
+    /(?:标题|名称|名字)(?:为|叫|是|:|:)\s*[“”"']?([^,,。;;\n“”"']{2,48})/u,
+  )?.[1]?.trim();
+  if (explicit) return explicit;
+  const withoutCreationPrefix = text.trim().replace(
+    /^(?:请|帮我|麻烦)?(?:创建|新建|开一个|新开)(?:一个)?(?:全新的?|新的?)?(?:普通)?\s*(?:Session|会话|工作|任务)?[,,::\s]*/iu,
+    '',
+  );
+  const firstClause = withoutCreationPrefix.split(/[,。;;\n]/u)[0]?.trim();
+  return firstClause?.slice(0, 48) || '新工作';
+}
+
+interface RouteCorrection {
+  text: string;
+  target: WorkHubSessionTarget;
+  sequence: number;
+}
+
+const MAX_RECENT_TARGETS = 8;
+const MAX_ROUTE_CORRECTIONS = 32;
+
+/**
+ * Deep routing module for R2.3.
+ *
+ * It owns only transient inference context. Session identity, transcript,
+ * execution state, and recovery continue to come from the Session port.
+ */
+export function createWorkHubRoutePolicy(): WorkHubRoutePolicy {
+  const recentTargets: WorkHubSessionTarget[] = [];
+  const corrections: RouteCorrection[] = [];
+  let correctionSequence = 0;
+
+  return {
+    resolve({ text, sessions, originPromptBySessionId, explicitTarget }) {
+      if (explicitTarget) {
+        return { kind: 'target', target: explicitTarget, evidence: 'explicit_target' };
+      }
+
+      if (looksLikeExplicitNewSession(text)) {
+        return { kind: 'new_session' };
+      }
+
+      const normalizedInput = normalizeIdentityText(text);
+      const exact = sessions.map((session) => {
+        const sessionName = normalizeIdentityText(session.sessionName);
+        const qualifiedName = normalizeIdentityText(
+          `${session.projectName}/${session.sessionName}`,
+        );
+        return {
+          session,
+          matchLength: normalizedInput.includes(qualifiedName)
+            ? qualifiedName.length
+            : normalizedInput.includes(sessionName)
+              ? sessionName.length
+              : 0,
+        };
+      }).filter(({ matchLength }) => matchLength >= 2)
+        .sort((left, right) => right.matchLength - left.matchLength);
+      if (exact[0] && exact[0].matchLength > (exact[1]?.matchLength ?? 0)) {
+        return {
+          kind: 'target',
+          target: exact[0].session.target,
+          evidence: 'exact_session_name',
+        };
+      }
+
+      const related = rankRelatedSessions(text, sessions, originPromptBySessionId);
+      if (looksLikeTargetUncertainty(text) && sessions.length > 0) {
+        const relatedIds = new Set(related.map(({ session }) => session.target.sessionId));
+        const options = [
+          ...related.map(({ session }) => session),
+          ...sessions
+            .filter((session) => !relatedIds.has(session.target.sessionId))
+            .sort((left, right) => right.updatedAt - left.updatedAt),
+        ];
+        return { kind: 'clarification', options: options.slice(0, 5) };
+      }
+
+      const corrected = correctedTarget(text, sessions, corrections);
+      if (corrected) {
+        return { kind: 'target', target: corrected, evidence: 'route_correction' };
+      }
+
+      const previousReference = looksLikePreviousFocus(text);
+      const currentReference = !previousReference && looksLikeRecentFocus(text);
+      const focused = previousReference
+        ? recentTargets[1]
+        : currentReference
+          ? recentTargets[0]
+          : undefined;
+      const strongEvidenceElsewhere = focused
+        ? related.some(({ session, longestMatch }) =>
+          session.target.sessionId !== focused.sessionId && longestMatch >= 4)
+        : false;
+      const ambiguousEvidence = related.length > 1;
+      if (
+        focused &&
+        (previousReference || (!strongEvidenceElsewhere && !ambiguousEvidence))
+      ) {
+        return { kind: 'target', target: focused, evidence: 'recent_focus' };
+      }
+
+      if (
+        related[0] &&
+        related[0].longestMatch >= 4 &&
+        (related[1]?.longestMatch ?? 0) < 4
+      ) {
+        return {
+          kind: 'target',
+          target: related[0].session.target,
+          evidence: 'core_entity',
+        };
+      }
+
+      const weakNewTopic = related.length === 1 &&
+        related[0]!.longestMatch < 4 &&
+        looksExecutable(text) &&
+        !currentReference;
+      if (related.length > 0 && !weakNewTopic) {
+        return {
+          kind: 'clarification',
+          options: related.slice(0, 4).map(({ session }) => session),
+        };
+      }
+      return looksExecutable(text) ? { kind: 'new_session' } : { kind: 'discussion' };
+    },
+    rememberTarget(target) {
+      const next = [
+        target,
+        ...recentTargets.filter((candidate) => candidate.sessionId !== target.sessionId),
+      ];
+      recentTargets.splice(0, recentTargets.length, ...next.slice(0, MAX_RECENT_TARGETS));
+    },
+    rememberCorrection(text, target) {
+      correctionSequence += 1;
+      corrections.unshift({ text, target, sequence: correctionSequence });
+      corrections.splice(MAX_ROUTE_CORRECTIONS);
+    },
+  };
+}
+
+function correctedTarget(
+  text: string,
+  sessions: WorkHubSessionFacts[],
+  corrections: readonly RouteCorrection[],
+): WorkHubSessionTarget | undefined {
+  const queryTerms = new Set(routingTerms(text));
+  if (queryTerms.size === 0) return undefined;
+  const available = new Set(sessions.map((session) => session.target.sessionId));
+  const ranked = corrections
+    .filter((correction) => available.has(correction.target.sessionId))
+    .map((correction) => {
+      const matches = routingTerms(correction.text).filter((term) => queryTerms.has(term));
+      return {
+        correction,
+        score: matches.reduce((total, term) => total + term.length, 0),
+        longestMatch: matches.reduce((longest, term) => Math.max(longest, term.length), 0),
+      };
+    })
+    .filter(({ longestMatch }) => longestMatch >= 3)
+    .sort((left, right) =>
+      right.score - left.score || right.correction.sequence - left.correction.sequence);
+  return ranked[0]?.correction.target;
+}
+
+function normalizeIdentityText(value: string): string {
+  return value.toLocaleLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
+}
+
+function looksLikeRecentFocus(value: string): boolean {
+  return /(?:它|这个(?:问题|工作|任务)?|这项(?:工作|任务)|刚才(?:那个|的)?|继续|接着|\bit\b|continue)/iu.test(
+    value,
+  );
+}
+
+function looksLikePreviousFocus(value: string): boolean {
+  return /(?:上一个|前一个|之前那个|回到.{0,6}(?:之前|上一个|前一个)|previous\s+(?:one|work)|go\s+back)/iu.test(
+    value,
+  );
+}
+
+function looksLikeTargetUncertainty(value: string): boolean {
+  return /(?:不确定(?:具体)?(?:是)?哪(?:一)?个|不知道(?:应该)?(?:选|继续|处理)哪(?:一)?个|可能是多个|哪个都可能)/iu.test(
+    value,
+  );
+}
+
+function looksLikeExplicitNewSession(value: string): boolean {
+  if (looksLikeCreationDeliberation(value)) return false;
+  return /(?:创建|新建|开一个|新开)(?:一个)?(?:全新的?|新的?)?(?:普通)?\s*(?:Session|会话|工作|任务)|(?:create|start|open)\s+(?:a\s+)?(?:brand[- ]new|new)\s+(?:session|work|task)/iu.test(
+    value,
+  );
+}
+
+function looksExecutable(value: string): boolean {
+  if (looksLikeCreationDeliberation(value)) return false;
+  const action = /(?:修复|修改|更新|实现|创建|新增|删除|移除|处理|完成|运行|测试|提交|推送|检查|优化|补充|整理|fix|implement|update|create|add|remove|delete|handle|finish|run|test|commit|push|check|optimize)/iu;
+  if (!action.test(value)) return false;
+  const directRequest = /(?:请|帮我|麻烦|现在(?:就)?|开始|can you|could you|would you|please)/iu.test(value);
+  if (directRequest) return true;
+  const designQuestion = /(?:[??]\s*$|怎么|如何|为什么|是否|该不该|值不值得|\bhow\b|\bwhy\b|\bwhether\b|\bwhat\b)/iu.test(
+    value,
+  );
+  return !designQuestion;
+}
+
+function looksLikeCreationDeliberation(value: string): boolean {
+  const creation = /(?:创建|新建|新开|开一个)(?:.{0,8})(?:Session|会话|工作|任务)|(?:create|start|open)(?:.{0,12})(?:session|work|task)/iu;
+  if (!creation.test(value)) return false;
+  return /(?:不要|别|无需|不用|不需要|先不|暂不|是否|要不要|该不该|应不应该|能不能|可不可以|为什么|如何|怎么|(?:do\s+not|don't|should\s+(?:we|i)|whether|why|how|can\s+(?:we|i)))/iu.test(
+    value,
+  ) || /[??]\s*$/u.test(value);
+}
+
+const ROUTING_STOP_TERMS = new Set([
+  '一下', '一个', '这个', '那个', '问题', '工作', '任务', '继续', '接着', '处理',
+  '检查', '修改', '更新', '实现', '完成', '分析', '风险', '测试', '测试点', '文件',
+  'please', 'continue', 'work', 'task',
+]);
+
+function rankRelatedSessions(
+  value: string,
+  sessions: WorkHubSessionFacts[],
+  originPromptBySessionId: ReadonlyMap<string, string | undefined>,
+): Array<{ session: WorkHubSessionFacts; score: number; longestMatch: number }> {
+  const terms = routingTerms(value);
+  return sessions
+    .map((session) => {
+      const identity = normalizeIdentityText([
+        session.sessionName,
+        originPromptBySessionId.get(session.target.sessionId) ?? '',
+        session.latestResult ?? '',
+      ].join(' '));
+      const matches = terms.filter((term) => identity.includes(term));
+      return {
+        session,
+        score: matches.reduce((total, term) => total + term.length, 0),
+        longestMatch: matches.reduce((longest, term) => Math.max(longest, term.length), 0),
+      };
+    })
+    .filter(({ score }) => score > 0)
+    .sort((left, right) =>
+      right.score - left.score || right.session.updatedAt - left.session.updatedAt);
+}
+
+function routingTerms(value: string): string[] {

Review Comment:
   **[P1, same finding] The scoring layer treats Chinese and English asymmetrically, and then applies one threshold to both.**
   
   `routingTerms` builds terms two different ways:
   
   - Chinese: every 2-to-6 character n-gram of each run, fully expanded. A 6-character phrase yields 15 terms, so `longestMatch` for a genuine phrase match is naturally 4–6.
   - English: `[a-z0-9_./-]{2,}` — whole tokens only, no n-grams. `longestMatch` is just the length of the longest matching word.
   
   Then `rankRelatedSessions` matches with `identity.includes(term)` against a `normalizeIdentityText` string that has had **all whitespace and punctuation removed**. For Chinese n-grams that is fine, since the terms are already substrings by construction. For English it means every term is matched as an unanchored substring of a concatenated blob, so word boundaries are gone.
   
   Concrete failure: a Session named `profile-settings` normalizes to `profilesettings`. A user types *"check the file parser"*. `file` survives (not a stop term), `identity.includes('file')` is true via **pro`file`settings**, giving `score = 4` and `longestMatch = 4`. If no other session matches at 4, this hits the `related[0].longestMatch >= 4 && (related[1]?.longestMatch ?? 0) < 4` branch and routes directly with evidence `core_entity` — no clarification. The word `file` had nothing to do with that session.
   
   The same `>= 4` threshold means "one meaningful Chinese phrase" and "any four-letter English word" — `test`, `data`, `main`, `code`, `file`, `user`. That asymmetry is why English input is more likely to route confidently *and* more likely to route wrong.
   
   Two changes would address the mechanism rather than the symptom: match Latin terms on word boundaries instead of raw `includes` (keeping the concatenated form only for the Chinese n-gram path), and give the Latin path its own threshold rather than sharing the Chinese one. Both are local to this function and `rankRelatedSessions`.
   
   A regression case worth pinning either way: the `profile-settings` / `"file"` shape above, asserting it yields clarification rather than a confident target.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org


Re: [PR] feat(desktop): add conservative WorkHub session router [maka]

Posted by "Astro-Han (via GitHub)" <gi...@apache.org>.
Astro-Han merged PR #3497:
URL: https://github.com/apache/maka/pull/3497


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org