Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@ function goal(id: string, activation: "active" | "stopped") {
};
}

// Host delivery-window diagnostics must survive the public status decoder so
// the personal-workspace view can distinguish observed staleness from absence.
const deliveryWindowStatus = basePayload({
run_history: {
available: true,
goal_count: 1,
run_count: 0,
goals: [{
...goal("window", "active"),
host_thread_activity: {
completeness: "complete",
threads: [{
agent_id: "codex",
host_surface: "codex-app",
state: "idle",
last_event_at: "2026-10-14T18:00:00Z",
delivery_window: {
schema_version: "loopx_host_delivery_window_v0",
state: "stale",
last_observed_at: "2026-10-14T18:00:00Z",
age_hours: 2.5,
expected_interval_minutes: 30,
window_minutes: 60,
tolerance: 2,
source: "installed_automation",
},
}],
},
}],
recent_runs: [],
},
});
equal(deliveryWindowStatus.run_history.goals[0].host_thread_activity?.threads[0].delivery_window?.state,
"stale", "host activity window state survives status parsing");
equal(deliveryWindowStatus.run_history.goals[0].host_thread_activity?.threads[0].delivery_window?.age_hours,
2.5, "host activity window age survives status parsing");

// A status source may omit totals while still returning a useful bounded list.
// Preserve that absence before the workspace adapter and typed digest see it.
for (const [schema, field] of [[todoGroupSchema, "done_count"], [projectAssetTodoSummarySchema, "done"]] as const) {
Expand Down
14 changes: 14 additions & 0 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,20 @@ export const runGoalSchema = z.object({
turn_started_at: z.string().optional().nullable(),
last_turn_ended_at: z.string().optional().nullable(),
last_event_at: z.string().optional().nullable(),
// This is cadence evidence about observed host activity, not proof that
// a scheduled prompt was delivered or an agent started.
delivery_window: z.object({
schema_version: z.string().optional(),
state: z.enum(["fresh", "stale", "missing", "unknown"]).catch("unknown"),
reason: z.enum(["no_expectation", "no_observation", "unparseable_observation"]).optional().nullable(),
last_observed_at: z.string().optional().nullable(),
age_seconds: z.number().optional().nullable(),
age_hours: z.number().optional().nullable(),
expected_interval_minutes: z.number().optional().nullable(),
window_minutes: z.number().optional().nullable(),
tolerance: z.number().optional().nullable(),
source: z.string().optional().nullable(),
}).optional().nullable(),
})).optional().default([]),
}).optional().nullable().catch(null),
index_exists: z.boolean().optional().default(false),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { goalHostSurfaces, goalIdentity, hostSurfaceLabel, presentGoalActivity, type GoalActivity, type WorkspaceGoalExecution } from "./goal-activity";
import { goalHostDeliveryWindows, goalHostSurfaces, goalIdentity, hostSurfaceLabel, presentGoalActivity, type GoalActivity, type WorkspaceGoalExecution } from "./goal-activity";
import { useWorkspaceI18n, type WorkspaceMessageKey } from "./i18n";
import type { WorkspaceGoal } from "./personal-workspace-model";

Expand Down Expand Up @@ -42,14 +42,35 @@ export function useExecutionDetail(execution: WorkspaceGoalExecution | undefined
return parts;
}

export function useGoalActivity(goal: GoalActivitySubject): GoalActivity & { text: string } {
export function useGoalActivity(goal: GoalActivitySubject): GoalActivity & {
text: string;
windowBadge: { state: "stale" | "missing"; labelKey: "activity.hostWindowStaleShort" | "activity.hostWindowMissingShort" } | null;
} {
const { t } = useWorkspaceI18n();
const activity = presentGoalActivity(goal);
const detail = useExecutionDetail(goal.execution);
const hostWindows = goalHostDeliveryWindows(goal);
const parts = [t(activity.labelKey as WorkspaceMessageKey)];
if (activity.alsoKey) parts.push(t(activity.alsoKey as WorkspaceMessageKey, { host: hostNames(goalHostSurfaces(goal)) }));
if (activity.labelKey === "activity.running" || activity.labelKey === "activity.hostClaimed") parts.push(...detail);
return { ...activity, text: parts.join(" · ") };
for (const hostWindow of hostWindows) {
const host = hostSurfaceLabel(hostWindow.hostSurface);
const agent = hostWindow.agentId || "Agent";
if (hostWindow.state === "stale") {
parts.push(t("activity.hostWindowStale", { host, agent, hours: Math.round((hostWindow.ageHours ?? 0) * 10) / 10 }));
} else if (hostWindow.state === "missing") {
parts.push(t("activity.hostWindowMissing", { host, agent }));
} else {
parts.push(t("activity.hostWindowFresh", { host, agent }));
}
}
const missedWindow = hostWindows.find((window) => window.state === "stale" || window.state === "missing");
const windowBadge = missedWindow?.state === "stale"
? { state: "stale" as const, labelKey: "activity.hostWindowStaleShort" as const }
: missedWindow?.state === "missing"
? { state: "missing" as const, labelKey: "activity.hostWindowMissingShort" as const }
: null;
return { ...activity, text: parts.join(" · "), windowBadge };
}

export function GoalIdentityMark({ goal, size = "md" }: { goal: GoalActivitySubject; size?: "md" | "lg" }) {
Expand All @@ -67,10 +88,12 @@ export function GoalIdentityMark({ goal, size = "md" }: { goal: GoalActivitySubj
}

export function GoalActivityChip({ goal }: { goal: GoalActivitySubject }) {
const { t } = useWorkspaceI18n();
const activity = useGoalActivity(goal);
return (
<span className={`personal-goal-activity-chip is-${activity.tone}${activity.live ? " is-live" : ""}`} title={activity.text}>
<span aria-hidden="true" className="personal-goal-activity-dot" />
{activity.windowBadge ? <span className={`personal-goal-window-badge is-${activity.windowBadge.state}`}>{t(activity.windowBadge.labelKey)}</span> : null}
<span>{activity.text}</span>
</span>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { goalExecution, goalWorkKind, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";
import { goalExecution, goalHostDeliveryWindows, goalWorkKind, presentGoalActivity, taskSessionStatus } from "../../../node_modules/.cache/loopx-goal-activity/goal-activity.js";

// One Goal's session facts, mixed by mode. `updated_at` stands for the time the
// owner last recorded anything for that session.
Expand All @@ -19,6 +19,20 @@ const recent = minutesAgo(2);
const silent = minutesAgo(30);
const old = minutesAgo(45);

assert.deepEqual(goalHostDeliveryWindows({ hostThreadActivity: { threads: [
{ agentId: "codex", hostSurface: "codex-app", state: "idle", lastEventAt: recent, deliveryWindow: { state: "stale", ageHours: 3.5 } },
{ agentId: "cursor-agent", hostSurface: "cursor", state: "idle", lastEventAt: recent, deliveryWindow: { state: "fresh", ageHours: 0.2 } },
] } }), [
{ agentId: "codex", hostSurface: "codex-app", state: "stale", ageHours: 3.5 },
{ agentId: "cursor-agent", hostSurface: "cursor", state: "fresh", ageHours: 0.2 },
]);
assert.deepEqual(goalHostDeliveryWindows({ hostThreadActivity: { threads: [
{ agentId: "codex", hostSurface: "codex-app", state: "unknown", lastEventAt: null, deliveryWindow: { state: "missing", reason: "no_observation" } },
] } }), [{ agentId: "codex", hostSurface: "codex-app", state: "missing", ageHours: null }]);
assert.deepEqual(goalHostDeliveryWindows({ hostThreadActivity: { threads: [
{ agentId: "codex", hostSurface: "codex-app", state: "idle", lastEventAt: recent, deliveryWindow: { state: "unknown", reason: "no_expectation" } },
] } }), [], "an unavailable cadence expectation is not a missed window");

function executionOf(sessions, observation) {
const execution = goalExecution(sessions, "goal-a", observation, now);
assert.equal(execution.kind, "running");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,15 @@ export function taskSessionStatus(

/** One bound host thread as observed by the host adapter (`host_thread_activity`). */
export type GoalHostThread = {
agentId?: string | null;
hostSurface: string;
state: "turn_open" | "idle" | "archived" | "unknown";
lastEventAt: string | null;
deliveryWindow?: {
state: "fresh" | "stale" | "missing" | "unknown";
reason?: string | null;
ageHours?: number | null;
} | null;
};

/** Coverage stays with the rows so a bounded sample cannot prove all hosts idle. */
Expand Down Expand Up @@ -143,6 +149,20 @@ export function goalHostSurfaces(goal: Pick<GoalActivityInput, "boundHostSurface
return Array.from(new Set([...(goal.boundHostSurfaces ?? []), ...attached]));
}

/** Cadence evidence shown separately from execution; unknown/no-expectation is not an alert. */
export function goalHostDeliveryWindows(goal: Pick<GoalActivityInput, "hostThreadActivity">) {
const windows = (goal.hostThreadActivity?.threads ?? [])
.filter((thread) => thread.deliveryWindow?.state === "stale" || thread.deliveryWindow?.state === "missing" || thread.deliveryWindow?.state === "fresh")
.map((thread) => ({
agentId: thread.agentId ?? null,
hostSurface: thread.hostSurface,
state: thread.deliveryWindow!.state as "fresh" | "stale" | "missing",
ageHours: thread.deliveryWindow?.ageHours ?? null,
}));
const rank = { stale: 0, missing: 1, fresh: 2 } as const;
return windows.sort((a, b) => rank[a.state] - rank[b.state]);
}

export type GoalActivityTone = "running" | "attention" | "danger" | "waiting" | "queued" | "quiet" | "stopped";

export type GoalActivity = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ function GoalRowActivity({ goal, showLoad }: { goal: WorkspaceGoal; showLoad: bo
const { t } = useWorkspaceI18n();
const activity = useGoalActivity(goal);
if (goal.loadState && showLoad) return <small>{t(goal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}</small>;
return <small className={`is-${activity.tone}`} title={activity.text}>{activity.text}</small>;
return (
<small className={`is-${activity.tone}`} title={activity.text}>
<span className="personal-goal-row-activity-copy">{activity.text}</span>
{activity.windowBadge ? <span className={`personal-goal-window-badge is-${activity.windowBadge.state}`}>{t(activity.windowBadge.labelKey)}</span> : null}
</small>
);
}

export function GoalSidebar({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,11 @@ const en = {
"activity.inHost": "handled by a {host} thread; the host owns live state",
"activity.hostClaimed": "Claimed by host",
"activity.hostIdle": "{host} thread idle",
"activity.hostWindowFresh": "{agent} on {host}: activity within its expected window",
"activity.hostWindowStale": "{agent} on {host}: activity outside its expected window ({hours} h)",
"activity.hostWindowMissing": "{agent} on {host}: activity not observed",
"activity.hostWindowStaleShort": "window stale",
"activity.hostWindowMissingShort": "no host activity",
"activity.claimedAt": "claimed {time}",
"activity.viaHost": "{host} host",
"activity.quiet": "no new activity for {minutes} min",
Expand Down Expand Up @@ -2821,6 +2826,11 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"activity.inHost": "由 {host} 线程负责,执行情况以宿主为准",
"activity.hostClaimed": "宿主已领取",
"activity.hostIdle": "{host} 线程空闲",
"activity.hostWindowFresh": "{agent}({host}):活动处于预期时间窗内",
"activity.hostWindowStale": "{agent}({host}):活动超出预期时间窗({hours} 小时)",
"activity.hostWindowMissing": "{agent}({host}):未观察到活动",
"activity.hostWindowStaleShort": "活动超时",
"activity.hostWindowMissingShort": "未观察到活动",
"activity.claimedAt": "领取于{time}",
"activity.viaHost": "{host} 宿主",
"activity.quiet": "已 {minutes} 分钟无新动静",
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1058,9 +1058,15 @@ function buildPersonalHomeModel(
hostThreadActivity: goal.host_thread_activity ? {
completeness: goal.host_thread_activity.completeness,
threads: goal.host_thread_activity.threads.map((thread) => ({
agentId: thread.agent_id ?? null,
hostSurface: thread.host_surface,
lastEventAt: thread.last_event_at ?? null,
state: thread.state,
deliveryWindow: thread.delivery_window ? {
state: thread.delivery_window.state,
ageHours: thread.delivery_window.age_hours ?? null,
reason: thread.delivery_window.reason ?? null,
} : null,
})),
} : undefined,
acceptanceObservation: goal.acceptance_observation,
Expand Down
Loading