Skip to content

Commit 40bb86c

Browse files
committed
fix(app): keep a host claim out of the running brief and lane
A Goal whose only execution fact is an attached host claim was still counted as "Running now" in the manager brief and as "执行中" in the home lane, so the first screen implied work was already advancing. Move the claim/execution decision into one shared read-model helper and route every consumer through it: the brief renders a claim-only Goal in its own "宿主已领取" tile, the home board gives it a matching lane, and timeline run rows no longer spin for a claim. The claim-only Goal keeps a clickable entry point with its claim time instead of borrowing execution. The browser scenario now asserts the four mixtures (attached-only, queued without a claim, stale managed with a fresh claim, fresh managed with an old claim) in Chinese and English, plus the tile and lane counts and widths. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
1 parent f7dc503 commit 40bb86c

9 files changed

Lines changed: 200 additions & 43 deletions

File tree

‎apps/presentation/dashboard/src/features/personal-workspace/goal-activity.test.mjs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { goalExecution, presentGoalActivity } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";
2+
import { goalExecution, goalWorkKind, presentGoalActivity } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";
33

44
// One Goal's session facts, mixed by mode. `updated_at` stands for the time the
55
// owner last recorded anything for that session.
@@ -97,4 +97,18 @@ for (const completeness of ["incomplete", undefined]) {
9797
assert.equal(partialIdle.alsoKey, "activity.inHost", "an incomplete sample never proves all threads idle");
9898
}
9999

100+
// `goalWorkKind` is the single rule the brief and the home lanes share: a claim
101+
// is never execution, and only a managed turn counts as executing.
102+
assert.equal(goalWorkKind({ execution: managed }), "executing", "A managed turn executes");
103+
assert.equal(goalWorkKind({ execution: quiet }), "executing", "A silent managed turn is still executing, not claimed");
104+
assert.equal(goalWorkKind({ execution: claim }), "claimed", "An attached-only claim is a claim, not execution");
105+
assert.equal(goalWorkKind({ execution: silentWithFreshClaim }), "executing", "A fresh claim never downgrades a managed turn");
106+
assert.equal(goalWorkKind({ execution: recentWithOldClaim }), "executing", "A stale claim never downgrades a managed turn");
107+
assert.equal(goalWorkKind({ execution: hostWithClaim }), "executing", "An observed host thread turn is execution, not a bare claim");
108+
assert.equal(goalWorkKind({ execution: { kind: "idle", hostSurfaces: [] } }), "none", "An idle Goal carries no unfinished turn");
109+
assert.equal(goalWorkKind({ execution: { kind: "unknown" } }), "none", "Unreadable execution is not unfinished work");
110+
assert.equal(goalWorkKind({}), "none", "A Goal without an execution fact carries no work kind");
111+
assert.equal(presentGoalActivity({ activationState: "active", execution: claim, state: "推进" }).labelKey, "activity.hostClaimed", "The chip names the claim");
112+
assert.equal(presentGoalActivity({ activationState: "active", execution: managed, state: "推进" }).labelKey, "activity.running", "The chip names managed execution");
113+
100114
console.log("Goal execution read-model invariants passed");

‎apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,29 @@ type GoalActivityInput = {
142142
state: string;
143143
};
144144

145+
/**
146+
* The unfinished-work fact a Goal carries, in the vocabulary its chip and lane
147+
* already show. `executing` needs a managed turn's own activity; an attached
148+
* host claim alone is `claimed` and is never counted as execution.
149+
*/
150+
export type GoalWorkKind = "executing" | "claimed" | "none";
151+
152+
/** `none` covers an idle, unreadable or absent execution, so only a live turn is actionable work. */
153+
export function goalWorkKind(goal: Pick<GoalActivityInput, "execution">): GoalWorkKind {
154+
if (goal.execution?.kind !== "running") return "none";
155+
return goal.execution.hostClaimed ? "claimed" : "executing";
156+
}
157+
145158
export function presentGoalActivity(goal: GoalActivityInput): GoalActivity {
146-
const execution = goal.activationState === "active" && goal.execution?.kind === "running" ? goal.execution : null;
147-
const running = execution !== null;
148-
const live = execution !== null && !execution.quiet && !execution.hostClaimed;
159+
const work = goal.activationState === "active" ? goalWorkKind(goal) : "none";
160+
const execution = work === "none" ? null : goal.execution as Extract<WorkspaceGoalExecution, { kind: "running" }>;
161+
const running = work !== "none";
162+
const live = work === "executing" && execution !== null && !execution.quiet;
149163
if (goal.activationState === "stopped" || goal.state === "已停止") return { labelKey: "state.stopped", tone: "stopped", live: false, alsoKey: null };
150164
if (goal.state === "等你" || goal.needsYou) return { labelKey: "state.needsYou", tone: "attention", live, alsoKey: running ? "activity.alsoRunning" : null };
151165
if (goal.state === "需修复") return { labelKey: "state.needsRepair", tone: "danger", live, alsoKey: running ? "activity.alsoRunning" : null };
152-
if (execution?.hostClaimed) return { labelKey: "activity.hostClaimed", tone: "running", live: false, alsoKey: null };
153-
if (running) return { labelKey: "activity.running", tone: live ? "running" : "attention", live, alsoKey: null };
166+
if (work === "claimed") return { labelKey: "activity.hostClaimed", tone: "running", live: false, alsoKey: null };
167+
if (work === "executing") return { labelKey: "activity.running", tone: live ? "running" : "attention", live, alsoKey: null };
154168
if (goal.state === "等待条件") return { labelKey: "state.waiting", tone: "waiting", live: false, alsoKey: null };
155169
if (goal.state === "已安排") {
156170
const hostThreads = goal.hostThreadActivity?.threads ?? [];

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ const en = {
468468
"home.lane.observingDescription": "Monitoring continuously and notifying you when something changes",
469469
"home.lane.running": "Running",
470470
"home.lane.runningDescription": "Agents are making progress and reporting back",
471+
"home.lane.claimed": "Claimed by host",
472+
"home.lane.claimedDescription": "A host took this work and LoopX is waiting for it to continue",
471473
"home.lane.scheduled": "Scheduled",
472474
"home.lane.scheduledDescription": "Queued until its time or prerequisite arrives",
473475
"home.noActivity": "No activity yet",
@@ -1093,6 +1095,8 @@ const en = {
10931095
"brief.runningEmptyQueued": "No Agent is running · {count} queued",
10941096
"brief.runningReading": "Reading run state…",
10951097
"brief.alsoQueued": "{count} more queued",
1098+
"brief.claimed": "Claimed by host",
1099+
"brief.claimedEmpty": "No work is with a host",
10961100
"brief.completed": "Recently completed",
10971101
"brief.completedEmpty": "No completed work yet",
10981102
"brief.completedTotal": "{count} completed in total",
@@ -1615,6 +1619,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
16151619
"home.lane.observingDescription": "持续监控,出现变化时再提醒你",
16161620
"home.lane.running": "执行中",
16171621
"home.lane.runningDescription": "Agent 正在推进并持续回传进展",
1622+
"home.lane.claimed": "宿主已领取",
1623+
"home.lane.claimedDescription": "宿主已取走工作,LoopX 等待其接续",
16181624
"home.lane.scheduled": "已安排",
16191625
"home.lane.scheduledDescription": "已经安排,等待时间或前置条件",
16201626
"home.noActivity": "尚无活动",
@@ -2240,6 +2246,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
22402246
"brief.runningEmptyQueued": "现在没有 Agent 在执行 · {count} 个已安排",
22412247
"brief.runningReading": "正在读取执行状态…",
22422248
"brief.alsoQueued": "另有 {count} 个已安排",
2249+
"brief.claimed": "宿主已领取",
2250+
"brief.claimedEmpty": "没有交给宿主的工作",
22432251
"brief.completed": "最近完成",
22442252
"brief.completedEmpty": "还没有完成的工作",
22452253
"brief.completedTotal": "累计完成 {count} 项",

‎apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ArrowRight, Check } from "lucide-react";
33
import { useWorkspaceI18n } from "./i18n";
44
import type { WorkspaceGoal } from "./personal-workspace-model";
55
import { workspaceHomeLaneForGoal } from "./personal-workspace-model";
6-
import { presentGoalActivity } from "./goal-activity";
6+
import { goalWorkKind, presentGoalActivity } from "./goal-activity";
77
import { GoalIdentityMark, useExecutionDetail } from "./goal-activity-view";
88

99
const briefRowLimit = 3;
@@ -17,7 +17,7 @@ function RunningMeta({ goal }: { goal: WorkspaceGoal }) {
1717
function BriefTile({ count, empty, kind, live = false, onSelectGoal, rows, title, total }: {
1818
count: number;
1919
empty: string;
20-
kind: "needs" | "running" | "completed";
20+
kind: "needs" | "running" | "claimed" | "completed";
2121
live?: boolean;
2222
onSelectGoal: (goalId: string) => void;
2323
rows: BriefRow[];
@@ -45,8 +45,9 @@ export function ManagerBrief({ goals, onSelectGoal }: { goals: WorkspaceGoal[];
4545
const { t } = useWorkspaceI18n();
4646
const active = goals.filter((goal) => goal.activationState === "active" && !goal.loadState);
4747
const needs = active.filter((goal) => workspaceHomeLaneForGoal(goal) === "needs_you");
48-
const running = active.filter((goal) => goal.execution?.kind === "running");
49-
const queued = active.filter((goal) => goal.state === "已安排" && goal.execution?.kind !== "running").length;
48+
const running = active.filter((goal) => goalWorkKind(goal) === "executing");
49+
const claimed = active.filter((goal) => goalWorkKind(goal) === "claimed");
50+
const queued = active.filter((goal) => goal.state === "已安排" && goalWorkKind(goal) === "none").length;
5051
const executionRead = active.some((goal) => goal.execution && goal.execution.kind !== "unknown");
5152
const executionPending = active.some((goal) => !goal.execution);
5253
const completed = active.flatMap((goal) => goal.agentTodos
@@ -64,6 +65,9 @@ export function ManagerBrief({ goals, onSelectGoal }: { goals: WorkspaceGoal[];
6465
<BriefTile count={running.length} empty={runningEmpty} kind="running" live={running.some((goal) => presentGoalActivity(goal).live)} onSelectGoal={onSelectGoal}
6566
rows={running.map((goal) => ({ goal, key: goal.goalId, meta: <RunningMeta goal={goal} />, text: goal.title }))}
6667
title={t("brief.running")} total={running.length && queued ? t("brief.alsoQueued", { count: queued }) : null} />
68+
{claimed.length ? <BriefTile count={claimed.length} empty={t("brief.claimedEmpty")} kind="claimed" onSelectGoal={onSelectGoal}
69+
rows={claimed.map((goal) => ({ goal, key: goal.goalId, meta: <RunningMeta goal={goal} />, text: goal.title }))}
70+
title={t("brief.claimed")} /> : null}
6771
<BriefTile count={completed.length} empty={t("brief.completedEmpty")} kind="completed" onSelectGoal={onSelectGoal}
6872
rows={completed} title={t("brief.completed")}
6973
total={completedTotal > completed.length ? t("brief.completedTotal", { count: completedTotal }) : null} />

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
44
const source = (name) => readFileSync(new URL(name, import.meta.url), "utf8");
55
const answerText = source("./answer-text.ts");
66
const model = source("./personal-workspace-model.ts");
7+
const activity = source("./goal-activity.ts");
78
const drawer = source("./context-drawer.tsx");
89
const header = source("./channel-header.tsx");
910
const sidebar = source("./goal-sidebar.tsx");
@@ -266,16 +267,18 @@ assert.match(chatData, /codexHostCapacitySchema/, "Codex host-capacity receipts
266267
assert.match(drawer, /subagentHostCapacityRaise/, "Goal sub-agent preview discloses a required Codex capacity raise");
267268
assert.match(drawer, /subagentAppliedRestart/, "Applied host capacity tells the operator that a new Session is required");
268269

269-
for (const lane of ["needs_you", "running", "observing", "scheduled", "history"]) {
270+
for (const lane of ["needs_you", "running", "claimed", "observing", "scheduled", "history"]) {
270271
assert.match(model, new RegExp(`"${lane}"`), `Manager home models the ${lane} lane`);
271272
}
272273
assert.match(model, /function workspaceHomeLaneForGoal/, "Manager lane projection is centralized and testable");
273-
assert.match(model, /goal\.execution\?\.kind === "running" \|\| goal\.state === "需修复"/, "Only an observed active turn or agent-owned repair work enters the running lane");
274-
for (const key of ["needsYou", "running", "observing", "scheduled"]) {
274+
assert.match(activity, /const work = goal\.activationState === "active" \? goalWorkKind\(goal\) : "none"/, "The claim/execution rule lives in one shared read-model helper");
275+
assert.match(model, /work === "executing"\) return "running"/, "Only a managed turn enters the running lane");
276+
assert.match(model, /work === "claimed"\) return "claimed"/, "A host claim gets its own lane instead of the running lane");
277+
for (const key of ["needsYou", "running", "claimed", "observing", "scheduled"]) {
275278
assert.match(page, new RegExp(`home\\.lane\\.${key}`), `Manager home renders localized ${key} lane copy`);
276279
}
277280
assert.match(page, /home\.history/, "Manager home renders localized history copy");
278-
assert.match(page, /personal-home-board/, "Manager home uses the four-lane workspace board");
281+
assert.match(page, /personal-home-board/, "Manager home uses the populated-lane workspace board");
279282
assert.doesNotMatch(page, /personal-worker-strip/, "Manager home omits the redundant Agent worker strip");
280283
assert.doesNotMatch(header, /切换到野兽主题|切换到默认主题/, "Workspace header does not expose theme switching");
281284
assert.match(workspaceTheme, /workspaceThemeStorageKey = "loopx-pw-theme"/, "Theme preference persists across reloads");

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ActionReviewPlan } from "../../../../../../loopx/control_plane/pre
44
import type { GoalAcceptanceObservation } from "../../data/goal-acceptance-observation";
55
import type { AttentionDetails } from "./attention-details";
66
import type { WorkspaceLoadError } from "../../data/workspace-progressive-status";
7-
import type { GoalHostThreadActivity, WorkspaceGoalExecution } from "./goal-activity";
7+
import { goalWorkKind, type GoalHostThreadActivity, type WorkspaceGoalExecution } from "./goal-activity";
88
export type WorkspaceGoalState =
99
| "需修复"
1010
| "等你"
@@ -17,6 +17,7 @@ export type WorkspaceGoalState =
1717
export type WorkspaceHomeLane =
1818
| "needs_you"
1919
| "running"
20+
| "claimed"
2021
| "observing"
2122
| "scheduled"
2223
| "history"
@@ -506,14 +507,19 @@ export function goalHasExecutionSummary(goal: Pick<WorkspaceGoal, "state">): boo
506507
}
507508

508509
/**
509-
* Project the detailed Goal lifecycle onto the five manager-home buckets.
510-
* The home shows populated active lanes and collapses terminal work into history.
510+
* Project the detailed Goal lifecycle onto the manager-home buckets. Execution
511+
* is read through the shared claim/execution rule, so a host claim gets its own
512+
* lane instead of borrowing the running one. The home shows populated active
513+
* lanes and collapses terminal work into history.
511514
*/
512515
export function workspaceHomeLaneForGoal(goal: WorkspaceGoal): WorkspaceHomeLane {
513516
if (goal.activationState === "stopped" || goal.state === "已停止") return "stopped";
514517
if (goal.state === "已完成") return "history";
515518
if (goal.needsYou || goal.state === "等你") return "needs_you";
516-
if (goal.execution?.kind === "running" || goal.state === "需修复") return "running";
519+
if (goal.state === "需修复") return "running";
520+
const work = goalWorkKind(goal);
521+
if (work === "executing") return "running";
522+
if (work === "claimed") return "claimed";
517523
if (goal.state === "安静运行") return "observing";
518524
return "scheduled";
519525
}

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import type {
6060
WorkspaceTodo,
6161
} from "./personal-workspace-model";
6262
import { goalHasExecutionSummary, goalTitleFor, workspaceHomeLaneForGoal } from "./personal-workspace-model";
63+
import { goalWorkKind } from "./goal-activity";
6364
import { WorkspaceActionForm, type WorkspaceActionDraft } from "./workspace-action-form";
6465
import { GoalActivityChip, GoalIdentityMark } from "./goal-activity-view";
6566
import { ManagerBrief } from "./manager-brief";
@@ -111,6 +112,7 @@ function ManagerHomeBoard({
111112
const activeHomeLanes = [
112113
{ key: "needs_you", label: t("home.lane.needsYou") },
113114
{ key: "running", label: t("home.lane.running") },
115+
{ key: "claimed", label: t("home.lane.claimed") },
114116
{ key: "observing", label: t("home.lane.observing") },
115117
{ key: "scheduled", label: t("home.lane.scheduled") },
116118
] as const;
@@ -373,7 +375,7 @@ function defaultTimeline(model: WorkspaceModel, selectedGoalId: string | null, t
373375
goalTitle: goal.title,
374376
latestActivity: goal.agentSentence,
375377
runId: `goal:${goal.goalId}`,
376-
status: goal.execution?.kind === "running" ? "running" : "failed",
378+
status: goalWorkKind(goal) === "executing" ? "running" : "failed",
377379
title: goal.nextSentence,
378380
totalSteps: Math.max(
379381
(goal.doneTodoCount ?? 0) + goal.agentTodos.filter((todo) => !todo.done).length,
@@ -410,7 +412,7 @@ function defaultTimeline(model: WorkspaceModel, selectedGoalId: string | null, t
410412
goalTitle: goal.title,
411413
latestActivity: goal.agentSentence,
412414
runId: `goal:${goal.goalId}`,
413-
status: goal.execution?.kind === "running" ? "running" : goal.state === "需修复" ? "failed" : goal.state === "已安排" ? "queued" : "waiting",
415+
status: goalWorkKind(goal) === "executing" ? "running" : goal.state === "需修复" ? "failed" : goal.state === "已安排" ? "queued" : "waiting",
414416
title: goal.nextSentence,
415417
totalSteps: Math.max(
416418
(goal.doneTodoCount ?? 0) + goal.agentTodos.filter((todo) => !todo.done).length,

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)