Skip to content

Commit f7dc503

Browse files
feat: show whether bound Codex threads are running in the App (#5081)
* feat(status): observe bound Codex thread activity for the App status route Add a read-only host thread observer contract with typed states (turn_open, idle, archived, unknown with a typed reason) and a Codex adapter that reads the local thread store and rollout turn markers. Unrecognized store or record shapes are unknown, never an open turn. Only the App status route attaches the result; other collect_status callers are unchanged. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(app): show observed host thread turns as execution An open host-recorded turn with a recent event runs the Goal with the live ring; silent turns disclose their silence and turns without an event for six hours are not execution. Observed idle threads read as idle instead of deferring to the host. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(status): keep partial and malformed host observations honest Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> * fix(status): preserve claim clocks and escaped event semantics Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --------- Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6c86b75 commit f7dc503

14 files changed

Lines changed: 947 additions & 52 deletions

File tree

‎apps/presentation/dashboard/src/data/status.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,20 @@ export const runGoalSchema = z.object({
527527
host_surface: z.string().optional().nullable(),
528528
})).optional().default([]).catch([]),
529529
}).optional().nullable(),
530+
host_thread_activity: z.object({
531+
observed_at: z.string().optional().nullable(),
532+
// Older or malformed observations cannot prove that every binding was read.
533+
completeness: z.enum(["complete", "incomplete"]).catch("incomplete").default("incomplete"),
534+
threads: z.array(z.object({
535+
agent_id: z.string().optional().nullable(),
536+
host_surface: z.string(),
537+
state: z.enum(["turn_open", "idle", "archived", "unknown"]).catch("unknown"),
538+
reason: z.string().optional().nullable(),
539+
turn_started_at: z.string().optional().nullable(),
540+
last_turn_ended_at: z.string().optional().nullable(),
541+
last_event_at: z.string().optional().nullable(),
542+
})).optional().default([]),
543+
}).optional().nullable().catch(null),
530544
index_exists: z.boolean().optional().default(false),
531545
raw_index_records: z.number().optional().default(0),
532546
unique_runs: z.number().optional().default(0),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { goalHostSurfaces, goalIdentity, hostSurfaceLabel, presentGoalActivity,
33
import { useWorkspaceI18n, type WorkspaceMessageKey } from "./i18n";
44
import type { WorkspaceGoal } from "./personal-workspace-model";
55

6-
type GoalActivitySubject = Pick<WorkspaceGoal, "activationState" | "boundHostSurfaces" | "execution" | "goalId" | "loadState" | "needsYou" | "state" | "title">;
6+
type GoalActivitySubject = Pick<WorkspaceGoal, "activationState" | "boundHostSurfaces" | "execution" | "goalId" | "hostThreadActivity" | "loadState" | "needsYou" | "state" | "title">;
77

88
export function relativeTime(value: string, locale: string): string | null {
99
const then = new Date(value).getTime();

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

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict";
2-
import { goalExecutionFromSessions, presentGoalActivity } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";
2+
import { goalExecution, 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.
@@ -19,8 +19,8 @@ const recent = minutesAgo(2);
1919
const silent = minutesAgo(30);
2020
const old = minutesAgo(45);
2121

22-
function executionOf(sessions) {
23-
const execution = goalExecutionFromSessions(sessions, "goal-a", now);
22+
function executionOf(sessions, observation) {
23+
const execution = goalExecution(sessions, "goal-a", observation, now);
2424
assert.equal(execution.kind, "running");
2525
return execution;
2626
}
@@ -66,9 +66,35 @@ assert.equal(presentGoalActivity({ activationState: "active", execution: recentW
6666

6767
// Several managed turns report the newest one; a closed session is not open work.
6868
assert.equal(executionOf([session("a", "managed", silent), session("b", "managed", recent)]).lastActivityAt, recent);
69-
assert.equal(goalExecutionFromSessions([{ ...session("closed", "managed", recent), status: "closed" }], "goal-a", now).kind, "idle");
70-
assert.equal(goalExecutionFromSessions([session("other", "managed", recent, null)], "goal-a", now).kind, "idle");
71-
assert.equal(goalExecutionFromSessions(null, "goal-a", now).kind, "unknown");
72-
assert.equal(goalExecutionFromSessions([session("other-goal", "managed", recent)], "goal-b", now).kind, "idle");
69+
assert.equal(goalExecution([{ ...session("closed", "managed", recent), status: "closed" }], "goal-a", undefined, now).kind, "idle");
70+
assert.equal(goalExecution([session("other", "managed", recent, null)], "goal-a", undefined, now).kind, "idle");
71+
assert.equal(goalExecution(null, "goal-a", undefined, now).kind, "unknown");
72+
assert.equal(goalExecution([session("other-goal", "managed", recent)], "goal-b", undefined, now).kind, "idle");
73+
74+
// Native observation joins actual activity; it does not turn a fresh attached
75+
// claim into execution or let that claim refresh a silent host Turn.
76+
const hostObservation = (lastEventAt, completeness = "complete") => ({
77+
completeness,
78+
threads: [{ hostSurface: "codex-app", state: "turn_open", lastEventAt }],
79+
});
80+
const hostWithClaim = executionOf([session("attached", "attached_host", recent)], hostObservation(silent));
81+
assert.equal(hostWithClaim.lastActivityAt, silent);
82+
assert.equal(hostWithClaim.claimedAt, recent);
83+
assert.equal(hostWithClaim.hostClaimed, false);
84+
assert.equal(hostWithClaim.quiet, true);
85+
assert.equal(presentGoalActivity({ activationState: "active", execution: hostWithClaim, state: "推进" }).live, false);
86+
const freshHost = executionOf([session("managed", "managed", silent)], hostObservation(recent, "incomplete"));
87+
assert.equal(freshHost.lastActivityAt, recent);
88+
assert.equal(freshHost.quiet, false);
89+
assert.equal(presentGoalActivity({ activationState: "active", execution: freshHost, state: "推进" }).live, true);
90+
assert.equal(goalExecution(undefined, "goal-a", hostObservation(recent), now).kind, "running");
91+
assert.equal(goalExecution(null, "goal-a", hostObservation(minutesAgo(8 * 60)), now).kind, "unknown");
92+
for (const completeness of ["incomplete", undefined]) {
93+
const partialIdle = presentGoalActivity({
94+
activationState: "active", state: "已安排", boundHostSurfaces: ["codex-app"],
95+
hostThreadActivity: { completeness, threads: [{ hostSurface: "codex-app", state: "idle", lastEventAt: recent }] },
96+
});
97+
assert.equal(partialIdle.alsoKey, "activity.inHost", "an incomplete sample never proves all threads idle");
98+
}
7399

74100
console.log("Goal execution read-model invariants passed");

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

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,9 @@
1-
// Execution is a fact read from the chat/session owner. Open Todos, quota
2-
// eligibility, registration or a persistent session never imply it.
3-
// Attached hosts only surface turns they claimed from LoopX, and the claim
4-
// timestamp is their only activity fact; work a host starts on its own, and
5-
// every turn of a bound host thread, is invisible here. Such Goals are
6-
// labelled as host-owned instead of running.
1+
// Execution is a fact read from the chat/session owner or from a host's own
2+
// thread records. Open Todos, quota eligibility, registration, a persistent
3+
// session or a thread binding never imply it. Attached hosts only surface turns
4+
// they claimed from LoopX, and the claim timestamp is their only activity fact.
75
export type WorkspaceGoalExecution =
8-
| {
9-
kind: "running";
10-
agentIds: string[];
11-
hostClaimed: boolean;
12-
hostSurfaces: string[];
13-
/**
14-
* Newest activity of a managed turn. An attached claim never writes this:
15-
* taking a turn is not evidence that anything executed.
16-
*/
17-
lastActivityAt: string | null;
18-
/** Newest claim time of an attached turn, reported as a claim rather than as activity. */
19-
claimedAt: string | null;
20-
quiet: boolean;
21-
sessionCount: number;
22-
}
6+
| { kind: "running"; hostClaimed: boolean; hostSurfaces: string[]; lastActivityAt: string | null; claimedAt: string | null; quiet: boolean }
237
| { kind: "idle"; hostSurfaces: string[] }
248
| { kind: "unknown" };
259

@@ -34,8 +18,23 @@ export type GoalSessionFact = {
3418
updated_at?: string | null;
3519
};
3620

37-
/** A claimed turn with no recorded event for this long may be silent or lost; it is never shown as live. */
21+
/** One bound host thread as observed by the host adapter (`host_thread_activity`). */
22+
export type GoalHostThread = {
23+
hostSurface: string;
24+
state: "turn_open" | "idle" | "archived" | "unknown";
25+
lastEventAt: string | null;
26+
};
27+
28+
/** Coverage stays with the rows so a bounded sample cannot prove all hosts idle. */
29+
export type GoalHostThreadActivity = {
30+
completeness: "complete" | "incomplete";
31+
threads: GoalHostThread[];
32+
};
33+
34+
/** A turn with no recorded event for this long may be silent or lost; it is never shown as live. */
3835
export const quietTurnMinutes = 15;
36+
/** A host can exit mid-turn without recording an end; after this long an open turn is not execution. */
37+
export const abandonedHostTurnHours = 6;
3938

4039
function hostSurfacesOf(sessions: readonly GoalSessionFact[]) {
4140
return Array.from(new Set(sessions
@@ -56,30 +55,47 @@ function newestRecordedTime(sessions: readonly GoalSessionFact[]) {
5655
.at(-1) ?? null;
5756
}
5857

59-
/** `sessions === null` means the session owner could not be read. */
60-
export function goalExecutionFromSessions(sessions: readonly GoalSessionFact[] | null, goalId: string, now = Date.now()): WorkspaceGoalExecution {
61-
if (sessions === null) return { kind: "unknown" };
62-
const open = sessions.filter((session) => session.goal_id === goalId && session.status !== "closed");
58+
function openHostTurns(threads: readonly GoalHostThread[], now: number) {
59+
return threads.filter((thread) => {
60+
const lastEventMs = thread.lastEventAt ? Date.parse(thread.lastEventAt) : Number.NaN;
61+
return thread.state === "turn_open" && !Number.isNaN(lastEventMs) && now - lastEventMs <= abandonedHostTurnHours * 3_600_000;
62+
});
63+
}
64+
65+
/**
66+
* `sessions === null` means the session owner could not be read; `undefined`
67+
* means it has not been read yet, so only host-observed turns are known.
68+
*/
69+
export function goalExecution(
70+
sessions: readonly GoalSessionFact[] | null | undefined,
71+
goalId: string,
72+
hostThreadActivity?: GoalHostThreadActivity,
73+
now = Date.now(),
74+
): WorkspaceGoalExecution | undefined {
75+
const hostTurns = openHostTurns(hostThreadActivity?.threads ?? [], now);
76+
const open = (sessions ?? []).filter((session) => session.goal_id === goalId && session.status !== "closed");
6377
const active = open.filter((session) => Boolean(session.active_turn_id));
64-
if (active.length === 0) return { kind: "idle", hostSurfaces: hostSurfacesOf(open) };
65-
// Aggregate by session mode: a managed turn carries execution activity, an
66-
// attached turn carries only its claim. Reading one maximum across both let a
67-
// recent claim make a silent managed turn look live.
78+
if (active.length === 0 && hostTurns.length === 0) {
79+
if (sessions === undefined) return undefined;
80+
return sessions === null ? { kind: "unknown" } : { kind: "idle", hostSurfaces: hostSurfacesOf(open) };
81+
}
82+
// Claims retain their own clock. Only managed activity and observed host
83+
// events can refresh execution, including in mixed-source observations.
6884
const managedTurns = active.filter((session) => !isAttachedTurn(session));
69-
const attachedTurns = active.filter(isAttachedTurn);
70-
const lastActivityAt = newestRecordedTime(managedTurns);
71-
const claimedAt = newestRecordedTime(attachedTurns);
72-
const hostClaimed = managedTurns.length === 0;
85+
const claimedAt = newestRecordedTime(active.filter(isAttachedTurn));
86+
const lastActivityAt = [
87+
newestRecordedTime(managedTurns),
88+
...hostTurns.map((thread) => thread.lastEventAt),
89+
].filter(Boolean).sort().at(-1) ?? null;
7390
const lastActivityMs = lastActivityAt ? Date.parse(lastActivityAt) : Number.NaN;
91+
const hostClaimed = hostTurns.length === 0 && managedTurns.length === 0;
7492
return {
7593
kind: "running",
76-
agentIds: Array.from(new Set(active.map((session) => session.agent_id))),
7794
hostClaimed,
78-
hostSurfaces: hostSurfacesOf(active),
95+
hostSurfaces: Array.from(new Set([...hostSurfacesOf(active), ...hostTurns.map((thread) => thread.hostSurface)])),
7996
lastActivityAt,
8097
claimedAt,
81-
quiet: !Number.isNaN(lastActivityMs) && now - lastActivityMs > quietTurnMinutes * 60_000,
82-
sessionCount: active.length,
98+
quiet: !hostClaimed && !Number.isNaN(lastActivityMs) && now - lastActivityMs > quietTurnMinutes * 60_000,
8399
};
84100
}
85101

@@ -88,6 +104,8 @@ const hostSurfaceNames: Readonly<Record<string, string>> = {
88104
"claude-code": "Claude Code",
89105
"codex-app": "Codex App",
90106
"codex-app-ssh": "Codex App (SSH)",
107+
"codex-cli-tui": "Codex CLI",
108+
"codex-ide-plugin": "Codex IDE",
91109
cursor: "Cursor",
92110
kiro: "Kiro",
93111
};
@@ -119,6 +137,7 @@ type GoalActivityInput = {
119137
/** `host_surface` of each thread bound to this Goal in the status projection. */
120138
boundHostSurfaces?: string[];
121139
execution?: WorkspaceGoalExecution;
140+
hostThreadActivity?: GoalHostThreadActivity;
122141
needsYou?: string | null;
123142
state: string;
124143
};
@@ -134,7 +153,12 @@ export function presentGoalActivity(goal: GoalActivityInput): GoalActivity {
134153
if (running) return { labelKey: "activity.running", tone: live ? "running" : "attention", live, alsoKey: null };
135154
if (goal.state === "等待条件") return { labelKey: "state.waiting", tone: "waiting", live: false, alsoKey: null };
136155
if (goal.state === "已安排") {
137-
const alsoKey = goal.execution?.kind === "unknown" ? "activity.executionUnknown" : goalHostSurfaces(goal).length > 0 ? "activity.inHost" : null;
156+
const hostThreads = goal.hostThreadActivity?.threads ?? [];
157+
const hostsIdle = goal.hostThreadActivity?.completeness === "complete"
158+
&& hostThreads.length > 0 && hostThreads.every((thread) => thread.state === "idle" || thread.state === "archived");
159+
const alsoKey = goal.execution?.kind === "unknown" ? "activity.executionUnknown"
160+
: hostsIdle ? "activity.hostIdle"
161+
: goalHostSurfaces(goal).length > 0 ? "activity.inHost" : null;
138162
return { labelKey: "state.queued", tone: "queued", live: false, alsoKey };
139163
}
140164
if (goal.state === "已完成") return { labelKey: "state.completed", tone: "quiet", live: false, alsoKey: null };

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,7 @@ const en = {
10551055
"activity.executionUnknown": "run state unavailable",
10561056
"activity.inHost": "handled by a {host} thread; the host owns live state",
10571057
"activity.hostClaimed": "Claimed by host",
1058+
"activity.hostIdle": "{host} thread idle",
10581059
"activity.claimedAt": "claimed {time}",
10591060
"activity.viaHost": "{host} host",
10601061
"activity.quiet": "no new activity for {minutes} min",
@@ -2201,6 +2202,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
22012202
"activity.executionUnknown": "暂时读不到执行状态",
22022203
"activity.inHost": "由 {host} 线程负责,执行情况以宿主为准",
22032204
"activity.hostClaimed": "宿主已领取",
2205+
"activity.hostIdle": "{host} 线程空闲",
22042206
"activity.claimedAt": "领取于{time}",
22052207
"activity.viaHost": "{host} 宿主",
22062208
"activity.quiet": "已 {minutes} 分钟无新动静",

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

Lines changed: 3 additions & 1 deletion
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 { WorkspaceGoalExecution } from "./goal-activity";
7+
import type { GoalHostThreadActivity, WorkspaceGoalExecution } from "./goal-activity";
88
export type WorkspaceGoalState =
99
| "需修复"
1010
| "等你"
@@ -107,6 +107,8 @@ export type WorkspaceGoal = {
107107
doneTodoCount?: number;
108108
execution?: WorkspaceGoalExecution;
109109
goalId: string;
110+
/** Bound host threads as observed from the host's own records. */
111+
hostThreadActivity?: GoalHostThreadActivity;
110112
latestActivity?: string;
111113
needsYou?: string | null;
112114
needsYouBlocking?: boolean;

‎apps/presentation/dashboard/src/views/dashboard-page.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ import {
113113
type WorkspaceActionPreview,
114114
type WorkspaceActionPreviewRequest,
115115
} from "../features/personal-workspace/personal-workspace-model";
116-
import { goalExecutionFromSessions } from "../features/personal-workspace/goal-activity";
116+
import { goalExecution } from "../features/personal-workspace/goal-activity";
117117

118118
const protectedOperationLabels: Record<ProtectedActionProposal["operation"], string> = {
119119
delete: "删除",
@@ -1112,6 +1112,14 @@ function buildPersonalHomeModel(
11121112
boundHostSurfaces: Array.from(new Set((goal.coordination?.thread_agent_bindings ?? [])
11131113
.flatMap((binding) => binding.host_surface ? [binding.host_surface] : []))),
11141114
doneTodoCount: agentTodoFacts.doneTodoCount,
1115+
hostThreadActivity: goal.host_thread_activity ? {
1116+
completeness: goal.host_thread_activity.completeness,
1117+
threads: goal.host_thread_activity.threads.map((thread) => ({
1118+
hostSurface: thread.host_surface,
1119+
lastEventAt: thread.last_event_at ?? null,
1120+
state: thread.state,
1121+
})),
1122+
} : undefined,
11151123
acceptanceObservation: goal.acceptance_observation,
11161124
goalId: goal.id,
11171125
latestActivity: row.latestRun?.generated_at ?? "",
@@ -2627,9 +2635,9 @@ function PersonalGoalHome({
26272635
const normalizedModel = normalizePersonalHomeModel(model);
26282636
const workspaceModel = {
26292637
...normalizedModel,
2630-
goals: goalSessionFacts === undefined ? normalizedModel.goals : normalizedModel.goals.map((goal) => ({
2638+
goals: normalizedModel.goals.map((goal) => ({
26312639
...goal,
2632-
execution: goalExecutionFromSessions(goalSessionFacts, goal.goalId),
2640+
execution: goalExecution(goalSessionFacts, goal.goalId, goal.hostThreadActivity),
26332641
})),
26342642
userTodos: model.userTodos.map(attentionForWorkspace),
26352643
attentionHistory: (model.attentionHistory ?? model.userTodos).map(attentionForWorkspace),

‎docs/integrations/attached-agent-session-broker.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,36 @@ Opaque host identifiers, message bodies, and response files stay in the local
7272
runtime store. Public Session projections contain only the LoopX Session id,
7373
Goal/Agent binding, executor endpoint label, host surface, capability booleans,
7474
and lifecycle state.
75+
76+
## Bound Codex Thread Activity
77+
78+
The broker only sees turns a host claims. A host thread registered with
79+
`bind-agent-thread` but never attached is observed separately, read-only, from
80+
the host's own local store. The App status route (`/status.json`) adds
81+
`run_history.goals[].host_thread_activity` with up to 32 bound-thread rows:
82+
`agent_id`, `host_surface`, `state`, and the `turn_started_at`,
83+
`last_turn_ended_at` and `last_event_at` timestamps when known. Thread ids,
84+
paths and message content are not included.
85+
86+
The observation's `completeness` is `complete` only when every valid binding
87+
fits within that read budget; otherwise it is `incomplete`. The App can show
88+
an observed recent open turn from either, but reports all hosts idle only from
89+
a complete observation containing only idle or archived threads. Missing or
90+
unrecognized completeness is treated as incomplete. Malformed event payloads
91+
make that thread `unknown` without failing the status route; a later recognized
92+
turn marker restores its observed state.
93+
94+
| `state` | Meaning |
95+
| --- | --- |
96+
| `turn_open` | The host recorded a turn start without an end. A host that exits mid-turn leaves this behind, so the App shows it as running only while `last_event_at` is recent. |
97+
| `idle` | The latest recorded turn ended. |
98+
| `archived` | The host archived the thread. |
99+
| `unknown` | Not observable; `reason` is `unsupported_host`, `store_unavailable`, `thread_not_found`, `record_unrecognized` or `no_turn_marker`. |
100+
101+
Codex local surfaces (`codex-app`, `codex-cli-tui`, `codex-ide-plugin`) are read
102+
from `<CODEX_HOME>/state_<n>.sqlite` and the thread's rollout JSONL. The Codex
103+
store is not a public contract: any shape the adapter does not recognize is
104+
`unknown`, never `turn_open`. Remote surfaces such as `codex-app-ssh` are
105+
`unsupported_host`. Homes are searched in the order `CODEX_HOME`, `~/.codex`,
106+
then sibling `~/.codex-*` directories; set `LOOPX_CODEX_HOMES` (an
107+
`os.pathsep`-separated list) to search exactly those homes instead.

‎examples/personal-workspace-browser/fixture.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
453453
},
454454
operatorCredentialWrites: [],
455455
turnRequests: [],
456+
hostThreadActivity: {},
456457
answerForMessage: null,
457458
loopxModeRequests: [],
458459
get larkConnections() { return runtime.larkConnections; },
@@ -687,6 +688,10 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
687688
},
688689
);
689690
}
691+
for (const [goalId, activity] of Object.entries(state.hostThreadActivity)) {
692+
const goal = fixture.run_history.goals.find((item) => item.id === goalId);
693+
if (goal) goal.host_thread_activity = activity;
694+
}
690695
const goalActivationScope = new URL(route.request().url()).searchParams.get("goal_activation");
691696
const isActiveScope = goalActivationScope === "active";
692697
const activeGoalCount = fixture.run_history.goals.filter((goal) => goal.activation_state !== "stopped").length;

0 commit comments

Comments
 (0)