feat: surface task execution progress across Host Agent and Cloud

Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:47:49 +08:00
co-authored by Claude Opus 4.6
parent c049c3c1b1
commit ec261d57c2
59 changed files with 3801 additions and 122 deletions
+11
View File
@@ -8,6 +8,8 @@ import type {
LlmProviderSettings,
LlmProviderType,
HostRecord,
PlannerDecisionItem,
PlannerDecisionListResponse,
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
@@ -232,6 +234,15 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
}
export function getTaskPlannerDecisions(
taskId: string,
attempt: number,
): Promise<PlannerDecisionItem[]> {
return request<PlannerDecisionListResponse>(
`/v1/tasks/${encodeURIComponent(taskId)}/planner-decisions?attempt=${attempt}`,
).then((resp) => resp.items);
}
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
return request<{ task_id: string }>("/v1/tasks", {
method: "POST",
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { computePlannerHistoryState } from "./plannerHistory";
import type { PlannerDecisionItem } from "./types";
function makeDecision(
overrides: Partial<PlannerDecisionItem> = {},
): PlannerDecisionItem {
return {
step_index: 0,
attempt: 0,
system_prompt: "system",
user_prompt: "user",
tool_name: "tap",
arguments: { target: "button" },
created_at: "2026-07-14T00:00:00+00:00",
...overrides,
};
}
describe("computePlannerHistoryState", () => {
it("returns populated when decisions exist (cloud transport)", () => {
const decisions = [
makeDecision({ step_index: 0, tool_name: "tap" }),
makeDecision({ step_index: 1, tool_name: "swipe" }),
];
const state = computePlannerHistoryState(decisions, "cloud");
expect(state).toEqual({ kind: "populated", decisions });
});
it("returns populated when decisions exist even if transport is direct", () => {
const decisions = [makeDecision()];
const state = computePlannerHistoryState(decisions, "direct");
expect(state).toEqual({ kind: "populated", decisions });
});
it("returns empty_cloud_transport when no decisions but host is cloud-transport", () => {
const state = computePlannerHistoryState([], "cloud");
expect(state).toEqual({ kind: "empty_cloud_transport" });
});
it("returns direct_transport_hidden when no decisions and host is direct-transport", () => {
const state = computePlannerHistoryState([], "direct");
expect(state).toEqual({ kind: "direct_transport_hidden" });
});
it("returns unknown_host_transport when host transport is null", () => {
const state = computePlannerHistoryState([], null);
expect(state).toEqual({ kind: "unknown_host_transport" });
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { PlannerDecisionItem } from "./types";
export type PlannerHistoryState =
| { kind: "populated"; decisions: PlannerDecisionItem[] }
| { kind: "empty_cloud_transport" }
| { kind: "direct_transport_hidden" }
| { kind: "unknown_host_transport" };
/**
* Decide which empty-state or populated-state message to show for a task's
* planner decision history, based on whether any decisions were returned and
* the assigned host's configured planner transport.
*
* - **populated**: decisions exist — render them.
* - **direct_transport_hidden**: host uses the `direct` transport, which never
* sends prompts to Cloud. Show an explicit "not reported" message instead of
* an empty list.
* - **empty_cloud_transport**: host IS cloud-transport but no decisions have
* been persisted yet (task just started or no steps executed).
* - **unknown_host_transport**: host not found or transport field not reported.
*/
export function computePlannerHistoryState(
decisions: PlannerDecisionItem[],
hostTransport: "direct" | "cloud" | null,
): PlannerHistoryState {
if (decisions.length > 0) {
return { kind: "populated", decisions };
}
if (hostTransport === "direct") {
return { kind: "direct_transport_hidden" };
}
if (hostTransport === "cloud") {
return { kind: "empty_cloud_transport" };
}
return { kind: "unknown_host_transport" };
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { formatTaskProgress } from "./taskProgress";
import type { TaskListItem } from "./types";
function makeTask(
overrides: Partial<TaskListItem> & Pick<TaskListItem, "status">,
): TaskListItem {
return {
id: "task-a",
goal: null,
workflow_definition_id: null,
assigned_device_id: null,
assigned_host_id: null,
attempt_count: 0,
failure_reason: null,
target_host_id: null,
target_device_id: null,
created_at: "2026-07-14T00:00:00+00:00",
progress_step_index: null,
progress_step_status: null,
progress_summary: null,
progress_updated_at: null,
...overrides,
};
}
describe("formatTaskProgress", () => {
it("renders step index, status, and summary for an in-progress dispatched task", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: 2,
progress_step_status: "running",
progress_summary: "tapping button",
});
expect(formatTaskProgress(task)).toBe("step 2 \u2014 running: tapping button");
});
it("renders step index and status when summary is missing", () => {
const task = makeTask({
status: "assigned",
progress_step_index: 0,
progress_step_status: "starting",
progress_summary: null,
});
expect(formatTaskProgress(task)).toBe("step 0 \u2014 starting");
});
it("returns null when the task is dispatched but no progress has been reported", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: null,
progress_step_status: null,
progress_summary: null,
});
expect(formatTaskProgress(task)).toBeNull();
});
it("returns null for a dispatched task with undefined progress fields", () => {
const task = makeTask({ status: "dispatched" });
// Fields default to null via makeTask; simulate the undefined case.
delete (task as Partial<TaskListItem>).progress_step_index;
delete (task as Partial<TaskListItem>).progress_step_status;
expect(formatTaskProgress(task)).toBeNull();
});
it("hides stale progress when the task has reached a terminal status (done)", () => {
const task = makeTask({
status: "done",
progress_step_index: 5,
progress_step_status: "running",
progress_summary: "stale snapshot before terminal",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("hides stale progress when the task has reached a terminal status (failed)", () => {
const task = makeTask({
status: "failed",
progress_step_index: 3,
progress_step_status: "running",
progress_summary: "stale snapshot before failure",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("hides progress for a queued task that has not started executing", () => {
const task = makeTask({
status: "queued",
progress_step_index: 1,
progress_step_status: "running",
progress_summary: "should not show while queued",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("ignores a missing step status even when step index is present", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: 1,
progress_step_status: null,
progress_summary: "partial snapshot",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("truncates a long summary with an ellipsis", () => {
const longSummary = "x".repeat(200);
const task = makeTask({
status: "dispatched",
progress_step_index: 1,
progress_step_status: "running",
progress_summary: longSummary,
});
const formatted = formatTaskProgress(task);
expect(formatted).not.toBeNull();
const summaryPart = formatted!.split(": ").slice(1).join(": ");
expect(summaryPart.length).toBeLessThan(longSummary.length);
expect(summaryPart.endsWith("\u2026")).toBe(true);
});
it("trims surrounding whitespace before truncating or rendering", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: 1,
progress_step_status: "running",
progress_summary: " hello world ",
});
expect(formatTaskProgress(task)).toBe("step 1 \u2014 running: hello world");
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { TaskListItem, TaskStatus } from "./types";
/**
* Statuses that represent an actively executing assignment, where live
* in-progress step information is meaningful to surface.
*/
const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
"assigned",
"dispatched",
]);
/**
* Format a task's latest in-progress step snapshot for inline display.
*
* Returns `null` when:
* - the task is not actively executing (queued, done, failed) — terminal/queued
* status always wins over a stale progress snapshot; or
* - no progress has been reported yet (`progress_step_index` is null/undefined
* or `progress_step_status` is missing).
*
* Otherwise returns a compact string like `"step 2 — running: tapping button"`.
* The summary is truncated to a display-friendly length with a Unicode ellipsis
* when it exceeds the limit.
*/
export function formatTaskProgress(
task: Pick<
TaskListItem,
| "status"
| "progress_step_index"
| "progress_step_status"
| "progress_summary"
>,
maxSummaryLength = 120,
): string | null {
if (!ACTIVE_STATUSES.has(task.status)) return null;
if (
task.progress_step_index === null ||
task.progress_step_index === undefined ||
!task.progress_step_status
) {
return null;
}
const summary = task.progress_summary ?? "";
const trimmed = summary.trim();
const display =
trimmed.length > maxSummaryLength
? `${trimmed.slice(0, maxSummaryLength - 1).trimEnd()}\u2026`
: trimmed;
return `step ${task.progress_step_index} \u2014 ${task.progress_step_status}${
display ? `: ${display}` : ""
}`;
}
+18
View File
@@ -17,6 +17,10 @@ export interface TaskListItem {
target_host_id: string | null;
target_device_id: string | null;
created_at: string;
progress_step_index?: number | null;
progress_step_status?: string | null;
progress_summary?: string | null;
progress_updated_at?: string | null;
}
export interface TaskSubmissionPayload {
@@ -178,3 +182,17 @@ export interface LlmProviderProfileListResponse {
settings: LlmProviderSettings;
items: LlmProviderProfile[];
}
export interface PlannerDecisionItem {
step_index: number;
attempt: number;
system_prompt: string;
user_prompt: string;
tool_name: string;
arguments: Record<string, unknown>;
created_at: string;
}
export interface PlannerDecisionListResponse {
items: PlannerDecisionItem[];
}
+169 -7
View File
@@ -4,6 +4,7 @@ import { LoaderCircle, RefreshCw } from "@lucide/vue";
import {
CloudApiError,
getTaskAttempts,
getTaskPlannerDecisions,
listTasks,
listDevices,
listHosts,
@@ -16,7 +17,10 @@ import type {
TaskStatus,
DeviceRecord,
HostRecord,
PlannerDecisionItem,
} from "../types";
import { formatTaskProgress } from "../taskProgress";
import { computePlannerHistoryState } from "../plannerHistory";
const props = defineProps<{ canSubmit: boolean }>();
@@ -48,6 +52,9 @@ const submitCapabilityTags = ref("");
const submitting = ref(false);
const hosts = ref<HostRecord[]>([]);
const devices = ref<DeviceRecord[]>([]);
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
const plannerLoading = ref(false);
const plannerError = ref("");
const availableDevices = computed(() =>
devices.value.filter((device) => device.host_id === submitHostId.value),
);
@@ -75,6 +82,7 @@ async function refresh() {
if (!stillPresent) {
selectedTask.value = null;
attempts.value = [];
plannerDecisions.value = [];
}
}
} catch (err) {
@@ -129,21 +137,51 @@ async function selectTask(task: TaskListItem) {
selectedTask.value = task;
attempts.value = [];
attemptsError.value = "";
plannerDecisions.value = [];
plannerError.value = "";
attemptsLoading.value = true;
// Fetch attempts first so we know which attempt numbers exist.
let loadedAttempts: TaskAttempt[] = [];
try {
attempts.value = await getTaskAttempts(task.id);
loadedAttempts = await getTaskAttempts(task.id);
attempts.value = loadedAttempts;
} catch (err) {
if (err instanceof CloudApiError && err.status === 404) {
// Task was deleted between list and detail load.
selectedTask.value = null;
await refresh();
} else {
handleError(err, "failed to load task attempts");
attemptsError.value = errorMessage.value;
errorMessage.value = "";
attemptsLoading.value = false;
return;
}
} finally {
handleError(err, "failed to load task attempts");
attemptsError.value = errorMessage.value;
errorMessage.value = "";
attemptsLoading.value = false;
return;
}
attemptsLoading.value = false;
// Fetch planner decisions for the latest attempt (if any).
const latestAttempt = loadedAttempts.length
? Math.max(...loadedAttempts.map((a) => a.attempt))
: task.attempt_count > 0
? task.attempt_count - 1
: null;
if (latestAttempt !== null) {
plannerLoading.value = true;
try {
plannerDecisions.value = await getTaskPlannerDecisions(task.id, latestAttempt);
} catch (err) {
// 404 on the task is already handled above; other errors are non-fatal.
if (!(err instanceof CloudApiError && err.status === 404)) {
handleError(err, "failed to load planner decisions");
plannerError.value = errorMessage.value;
errorMessage.value = "";
}
} finally {
plannerLoading.value = false;
}
}
}
@@ -170,6 +208,8 @@ function goNextPage() {
function clearSelection() {
selectedTask.value = null;
attempts.value = [];
plannerDecisions.value = [];
plannerError.value = "";
}
watch(statusFilter, () => {
@@ -212,6 +252,30 @@ function formatTerminalResult(attempt: TaskAttempt): string {
return String(attempt.terminal_result);
}
}
const selectedTaskProgress = computed(() =>
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
);
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
if (!selectedTask.value?.assigned_host_id) return null;
const host = hosts.value.find(
(h) => h.host_id === selectedTask.value?.assigned_host_id,
);
return host?.planner_transport ?? null;
});
const plannerHistoryState = computed(() =>
computePlannerHistoryState(plannerDecisions.value, selectedHostTransport.value),
);
function formatArguments(args: Record<string, unknown>): string {
try {
return JSON.stringify(args, null, 2);
} catch {
return String(args);
}
}
</script>
<template>
@@ -305,6 +369,13 @@ function formatTerminalResult(attempt: TaskAttempt): string {
<div v-if="task.failure_reason" class="dim">
{{ task.failure_reason }}
</div>
<div
v-if="formatTaskProgress(task)"
class="task-progress"
:title="formatTaskProgress(task) ?? ''"
>
{{ formatTaskProgress(task) }}
</div>
</td>
<td>
<div v-if="task.assigned_device_id">
@@ -363,6 +434,10 @@ function formatTerminalResult(attempt: TaskAttempt): string {
<strong class="text-danger">Failure reason:</strong>
{{ selectedTask.failure_reason }}
</p>
<p v-if="selectedTaskProgress" class="task-progress-detail">
<strong>Current step:</strong>
<code>{{ selectedTaskProgress }}</code>
</p>
<h3>Attempt history</h3>
<div v-if="attemptsLoading" class="muted">loading attempts</div>
@@ -402,6 +477,93 @@ function formatTerminalResult(attempt: TaskAttempt): string {
</tbody>
</table>
<div v-else class="muted">No attempts recorded for this task yet.</div>
<h3>LLM interaction history</h3>
<div v-if="plannerLoading" class="muted">loading planner decisions</div>
<div v-else-if="plannerError" class="notice error">{{ plannerError }}</div>
<div v-else-if="plannerHistoryState.kind === 'populated'" class="planner-history">
<div
v-for="decision in plannerHistoryState.decisions"
:key="`${decision.attempt}-${decision.step_index}`"
class="planner-decision"
>
<div class="planner-decision-header">
<span class="status-badge queued">step {{ decision.step_index }}</span>
<code>{{ decision.tool_name }}</code>
<span class="dim">{{ formatTime(decision.created_at) }}</span>
</div>
<details>
<summary>User prompt</summary>
<pre class="planner-prompt">{{ decision.user_prompt }}</pre>
</details>
<details v-if="decision.system_prompt">
<summary>System prompt</summary>
<pre class="planner-prompt">{{ decision.system_prompt }}</pre>
</details>
<details>
<summary>Arguments</summary>
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
</details>
</div>
</div>
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
No planner decisions reported yet for this task.
</div>
<div v-else-if="plannerHistoryState.kind === 'direct_transport_hidden'" class="muted">
This host uses the <code>direct</code> planner transport and does not report LLM interactions to Cloud.
</div>
<div v-else class="muted">
Host transport unknown; no planner decisions available.
</div>
</div>
</div>
</template>
<style scoped>
.task-progress {
margin-top: 4px;
color: var(--text-dim, #888);
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-progress-detail {
margin: 4px 0;
}
.task-progress-detail code {
font-size: 13px;
}
.planner-history {
display: flex;
flex-direction: column;
gap: 12px;
}
.planner-decision {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
padding: 8px 12px;
}
.planner-decision-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.planner-prompt {
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
margin: 4px 0 0;
}
</style>