diff --git a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs index 0040a8a4f6..048ea9598f 100644 --- a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs @@ -10,9 +10,39 @@ await build({ configFile: false, logLevel: "silent", build: { lib: { entry: resolve("src/data/workspace-progressive-status.ts"), formats: ["es"], fileName: () => "loader.mjs" }, rolldownOptions: { external: ["zod"] }, } }); -const { loadWorkspaceGoalSnapshots, directoryStatusPayload } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href); +const { loadWorkspaceGoalSnapshots, directoryStatusPayload, reusableGoalSnapshots } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href); const directory = { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r1", goals: [{ id: "alpha", display_name: "Alpha", activation_state: "active", registry_member: true }] }; + +// What a same-source refresh may keep: a paused Goal must not send its peers +// back to the loading lane, and the touched Goal must not survive as a stale +// snapshot. +const entry = (id, activation_state = "active", display_name = id) => + ({ id, display_name, activation_state, registry_member: true }); +const snapshots = { alpha: { alpha: true }, beta: { beta: true }, gamma: { gamma: true } }; +const earlier = { + directory: { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r1", + goals: [entry("alpha"), entry("beta"), entry("gamma")] }, + snapshots, +}; +const paused = { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r2", + goals: [entry("alpha"), entry("beta", "stopped"), entry("gamma")] }; +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused)), ["alpha", "gamma"], + "a Goal whose lifecycle moved is re-read instead of reused"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["gamma"] })), ["alpha"], + "a Goal the action touched is re-read even when its directory entry held"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["beta"] })), ["alpha", "gamma"], + "invalidating the Goal that already changed keeps its peers"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, { + ...paused, goals: [entry("alpha"), entry("gamma"), entry("delta")], +})), ["alpha", "gamma"], "a Goal that left the directory loses its snapshot"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, { + ...paused, goals: [entry("alpha"), entry("beta", "active", "Renamed"), entry("gamma")], +})), ["alpha", "gamma"], "a renamed Goal is re-read rather than reused under its new title"); +assert.deepEqual(reusableGoalSnapshots(null, paused), {}, "a first read has nothing to reuse"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["alpha", "gamma"] })), [], + "invalidating every Goal is a full re-read"); +const retentionChecks = 7; const access = { error_code: "workspace_status_access_denied" }; const original = { fetch, setTimeout, clearTimeout }; const deadline = {}; @@ -79,7 +109,7 @@ try { const payload = { ...directoryStatusPayload(directory), workspace_registry_revision: "r1" }; await check("success", () => json(payload, 200), ["success"], 1); await check("wrong Goal", () => json({ ...payload, run_history: { ...payload.run_history, goals: [{ id: "beta" }] } }, 200), ["scope"], 1); - console.log(JSON.stringify({ ok: true, checks })); + console.log(JSON.stringify({ ok: true, checks: checks + retentionChecks })); } finally { globalThis.fetch = original.fetch; globalThis.setTimeout = original.setTimeout; diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index efb433cec0..2341796f30 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -20,6 +20,33 @@ export type WorkspaceProgress = { errors: Record; }; +/** + * Snapshots a same-source refresh may keep instead of re-reading. + * + * A directory entry is the cheap authoritative signal for "this Goal's + * lifecycle did not move here": when the entry is unchanged, its snapshot still + * describes the Goal, and re-reading it costs one full status collection per + * Goal. Goals the caller just acted on are never reused, and a Goal that left + * the directory loses its snapshot with it. + */ +export function reusableGoalSnapshots( + previous: Pick | null, + directory: WorkspaceDirectory, + options: { invalidateGoalIds?: Iterable } = {}, +): Record { + if (!previous) return {}; + const invalidated = new Set(options.invalidateGoalIds ?? []); + const before = new Map(previous.directory.goals.map((goal) => [goal.id, goal])); + return Object.fromEntries(directory.goals.flatMap((goal) => { + const earlier = before.get(goal.id); + const snapshot = previous.snapshots[goal.id]; + if (!earlier || !snapshot || invalidated.has(goal.id)) return []; + if (earlier.display_name !== goal.display_name + || earlier.activation_state !== goal.activation_state) return []; + return [[goal.id, snapshot]]; + })); +} + function queryUrl(url: string, fields: Record, base: string) { const parsed = new URL(url, base); parsed.searchParams.delete("goal_activation"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index da085993a8..53bb9fbc1b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -390,7 +390,9 @@ export type PersonalWorkspaceCallbacks = { projectionVerified: boolean; }>; onGoalDeleted?: (goalId: string) => void; - onReconcileStatus?: () => void | Promise; + /** Re-read the workspace projection after an applied action. `invalidateGoalIds` + * names the Goals the action touched, so a peer's snapshot is not re-read for it. */ + onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh?: () => void | Promise; onRetryGoalArchive?: () => void | Promise; onPreviewAction?: (request: WorkspaceActionPreviewRequest) => WorkspaceActionPreview | Promise; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index c9d25141a2..82af55ee3b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1194,7 +1194,7 @@ export function PersonalWorkspacePage({ callbacks.onGoalActivationStateChange?.(goal.goalId, result.activationState); setActionFeedback(t("feedback.completed", { title: summaryByOperation[operation] })); if (operation === "stop") selectGoal(null); - await (callbacks.onReconcileStatus ?? callbacks.onRefresh)?.(); + await reconcileStatus([goal.goalId]); return; } const proposal = await createPreview({ @@ -1320,6 +1320,22 @@ export function PersonalWorkspacePage({ } } + /** + * Reconcile the projection after an applied action. The touched Goal is the + * only one whose snapshot is dropped; a peer keeps the snapshot it already + * had, so one Goal's pause does not send the rest of the workspace back to + * its loading lane. + */ + function reconcileStatus(invalidateGoalIds?: string[]) { + const reconcile = callbacks.onReconcileStatus; + const request = reconcile + ? reconcile({ invalidateGoalIds }) + : callbacks.onRefresh?.(); + return Promise.resolve(request).catch(() => { + setActionFeedback(t("feedback.goalRefreshFailed")); + }); + } + async function applyProposal( proposal: WorkspaceActionPreview, options: { @@ -1362,8 +1378,7 @@ export function PersonalWorkspacePage({ if (proposal.lifecycleOperation === "delete" && proposal.goalId) { callbacks.onGoalDeleted?.(proposal.goalId); } - const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh; - void Promise.resolve().then(() => reconcile?.()).catch(() => undefined); + void reconcileStatus(proposal.goalId ? [proposal.goalId] : undefined); } return; } @@ -1404,8 +1419,7 @@ export function PersonalWorkspacePage({ callbacks.onGoalDeleted?.(applied.goalId); } if (applied.actionKind === "goal.lifecycle") { - const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh; - void Promise.resolve().then(() => reconcile?.()).catch(() => undefined); + void reconcileStatus(applied.goalId ? [applied.goalId] : undefined); } } catch (error) { if (lifecycleChange) { @@ -1460,10 +1474,7 @@ export function PersonalWorkspacePage({ }, onOpenGoal: (goalId) => { selectGoal(goalId); - const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh; - void Promise.resolve().then(() => reconcile?.()).catch(() => { - setActionFeedback(t("feedback.goalRefreshFailed")); - }); + void reconcileStatus([goalId]); }, onOpenGoalView: (tab) => { setSelectedGoalTab(tab); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index e2ba346c63..4dc1ede6c0 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1,7 +1,7 @@ import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; import { attentionDetails, sourceAttention } from "../features/personal-workspace/attention-details"; import type { AttentionDetails } from "../features/personal-workspace/attention-details"; -import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, type WorkspaceProgress, type WorkspaceLoadError } from "../data/workspace-progressive-status"; +import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, reusableGoalSnapshots, type WorkspaceProgress, type WorkspaceLoadError } from "../data/workspace-progressive-status"; import { useEffect, useMemo, useRef, useState } from "react"; import { CircleAlert, Moon, RefreshCw, Sun } from "lucide-react"; @@ -1369,7 +1369,7 @@ function PersonalGoalHome({ onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void; onGoalDeleted: (goalId: string) => void; onSelectGoal: (goalId: string) => void; - onReconcileStatus: () => void | Promise; + onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh: () => void | Promise; onRetryGoalArchive: () => void | Promise; payload: StatusPayload; @@ -3088,6 +3088,8 @@ export function DashboardPage() { options: { background?: boolean; retryOnly?: boolean; + reuseSnapshots?: boolean; + invalidateGoalIds?: string[]; resyncAttempt?: number; selectionRevision?: number; } = {}, @@ -3118,8 +3120,14 @@ export function DashboardPage() { const directory = await fetchWorkspaceDirectory(trimmed, window.location.href).catch(() => null); if (!statusRequestCanCommit(statusRequestFenceRef.current, request)) return; if (directory) { - const retained = options.retryOnly && source.kind === "url" && source.label === trimmed - && progress?.directory.registry_revision === directory.registry_revision ? progress.snapshots : {}; + // A refresh that keeps the same source only re-reads the Goals whose + // directory entry moved or that the caller just acted on. Dropping every + // snapshot here would send the whole workspace back to its loading lane + // after one Goal's pause, resume or open. + const retained = (options.retryOnly || options.reuseSnapshots) + && source.kind === "url" && source.label === trimmed + ? reusableGoalSnapshots(progress, directory, { invalidateGoalIds: options.invalidateGoalIds }) + : {}; setProgress({ directory, snapshots: retained, errors: {} }); const requestedDirectory = { ...directory, goals: directory.goals.filter((goal) => !retained[goal.id]) }; let directoryChanged = false; @@ -3351,9 +3359,9 @@ export function DashboardPage() { ) } : current); }} onSelectGoal={selectGoal} - onReconcileStatus={() => loadFromUrl( + onReconcileStatus={(options) => loadFromUrl( source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), - { background: true }, + { background: true, invalidateGoalIds: options?.invalidateGoalIds, reuseSnapshots: true }, )} onRetryGoalArchive={retryGoalArchive} onRefresh={() => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { retryOnly: Boolean(progress && Object.keys(progress.errors).length) })} diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 7c665a9681..16872f5371 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -22,10 +22,11 @@ import { startServer, } from "./personal-workspace-browser/fixture.mjs"; import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; +import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; -const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario]; +const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario, progressiveLoadingScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 543623a8fc..4c0c92eefe 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -261,9 +261,28 @@ function capturedStatusGeneration(state) { return state.capturedStatusGeneration; } -function filterStatusFixtureToScope(fixture, statusGeneration, scope) { - const activationForGoal = (goalId) => statusGeneration.get(goalId) ?? "active"; - const matchesScope = (goalId) => activationForGoal(goalId) === scope; +/** The registry revision the progressive loader fences every Goal read against. */ +function workspaceRegistryRevision(state) { + return [...capturedStatusGeneration(state).entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([goalId, activationState]) => `${goalId}:${activationState}`) + .join("|"); +} + +/** + * Goals the fixture injects into both the workspace directory and the status + * payload. One list keeps a Goal's registered name identical in a per-Goal read + * and in the directory entry that outlives its snapshot. + */ +const directoryGoalFixtures = [ + { id: "product-release", display_name: "Product Release" }, + { id: "research-monitor", display_name: "Research Monitor" }, + { id: "progress-projection", display_name: "Progress Projection" }, + { id: "legacy-benchmark", display_name: "Legacy Benchmark" }, + { id: "archived-notes", display_name: "Archived Notes" }, +]; + +function filterStatusFixtureToScope(fixture, matchesScope) { fixture.attention_queue.items = fixture.attention_queue.items.filter((item) => matchesScope(item.goal_id)); fixture.attention_queue.item_count = fixture.attention_queue.items.length; if (fixture.todo_index?.items) { @@ -288,7 +307,7 @@ function filterStatusFixtureToScope(fixture, statusGeneration, scope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, runtimeAgents = null } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, progressiveWorkspace = false, runtimeAgents = null } = {}) { let turnCounter = 0; const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() }; const actionProposals = runtime.actionProposals; @@ -359,12 +378,34 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true capturedStatusGeneration: null, activationChangeAfterCapturedActive: null, statusRequestCount: 0, + // Progressive loading reads one Goal at a time; the scenario asserts which + // Goals a single action sent back through the loader. + goalStatusRequests: [], + workspaceDirectoryRequests: 0, turnRequests: [], get larkConnections() { return runtime.larkConnections; }, get goalSubagentConfigurations() { return runtime.goalSubagentConfigurations; }, }; await page.route(`http://127.0.0.1:${port}/status.json*`, async (route) => { state.statusRequestCount += 1; + const requestUrl = new URL(route.request().url()); + const requestedGoalId = requestUrl.searchParams.get("goal_id"); + if (progressiveWorkspace && requestUrl.searchParams.get("view") === "workspace-directory") { + state.workspaceDirectoryRequests += 1; + // The directory carries the same registered names as the per-Goal + // payloads, so a Goal keeps its title while it has no snapshot. + const registeredNames = new Map(directoryGoalFixtures.map((goal) => [goal.id, goal.display_name])); + await route.fulfill({ contentType: "application/json", json: { + ok: true, + schema_version: "loopx_workspace_directory_v1", + registry_revision: workspaceRegistryRevision(state), + goals: [...state.goalActivationStates.entries()].map(([id, activation_state]) => ({ + activation_state, display_name: registeredNames.get(id) ?? id, id, registry_member: true, + })), + }, status: 200 }); + return; + } + if (progressiveWorkspace && requestedGoalId) state.goalStatusRequests.push(requestedGoalId); const fixture = structuredClone(require(resolve(repoRoot, "examples/status.example.json"))); const defaultSubagentConfiguration = { mode: "default", spawn_allowed: false, max_children: 0, allowed_domains: [] }; const projectedSubagentConfiguration = (goalId, fallback) => state.freezeGoalSubagentStatusProjection @@ -376,14 +417,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true periodic_report_index_url: "/periodic-report-workspace", periodic_report_detail_url: "/periodic-report-workspace-projection", }; - const directoryFixtures = [ - { id: "product-release", display_name: "Product Release" }, - { id: "research-monitor", display_name: "Research Monitor" }, - { id: "progress-projection", display_name: "Progress Projection" }, - { id: "legacy-benchmark", display_name: "Legacy Benchmark" }, - { id: "archived-notes", display_name: "Archived Notes" }, - ]; - for (const directoryGoal of directoryFixtures) { + for (const directoryGoal of directoryGoalFixtures) { const activation_state = statusGeneration.get(directoryGoal.id) ?? "active"; const existingGoal = fixture.run_history.goals.find((goal) => goal.id === directoryGoal.id); if (existingGoal) { @@ -584,10 +618,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const isActiveScope = goalActivationScope === "active"; const activeGoalCount = fixture.run_history.goals.filter((goal) => goal.activation_state !== "stopped").length; const stoppedGoalCount = fixture.run_history.goals.length - activeGoalCount; - const registryRevision = [...statusGeneration.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([goalId, activationState]) => `${goalId}:${activationState}`) - .join("|"); + const registryRevision = workspaceRegistryRevision(state); const delayMs = state.nextStatusDelayMs; state.nextStatusDelayMs = 0; if (delayMs > 0) await new Promise((resolveWait) => setTimeout(resolveWait, delayMs)); @@ -596,6 +627,25 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true await route.fulfill({ contentType: "application/json", json: { error: "temporary status failure" }, status: 503 }); return; } + if (progressiveWorkspace) { + // The real service answers a per-Goal read with exactly that Goal and the + // registry revision the directory was read at. + fixture.workspace_registry_revision = registryRevision; + if (requestedGoalId) { + fixture.goal_projection = { + schema_version: "loopx_goal_projection_scope_v0", + scope: "active", + complete: true, + projected_goal_count: 1, + registry_goal_count: fixture.run_history.goals.length, + registry_revision: registryRevision, + }; + fixture.run_history.goals = fixture.run_history.goals.filter((goal) => goal.id === requestedGoalId); + filterStatusFixtureToScope(fixture, (goalId) => goalId === requestedGoalId); + await route.fulfill({ contentType: "application/json", json: fixture, status: 200 }); + return; + } + } if (isActiveScope) { fixture.goal_projection = { schema_version: "loopx_goal_projection_scope_v0", @@ -606,7 +656,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true registry_revision: registryRevision, }; fixture.run_history.goals = fixture.run_history.goals.filter((goal) => goal.activation_state !== "stopped"); - filterStatusFixtureToScope(fixture, statusGeneration, "active"); + filterStatusFixtureToScope(fixture, (goalId) => (statusGeneration.get(goalId) ?? "active") === "active"); // Freeze only the first half of the active-first read. The stopped // request must observe the registry after the intervening lifecycle // change so this fixture exercises the cross-snapshot revision fence. @@ -635,7 +685,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true registry_revision: registryRevision, }; fixture.run_history.goals = fixture.run_history.goals.filter((goal) => goal.activation_state === "stopped"); - filterStatusFixtureToScope(fixture, statusGeneration, "stopped"); + filterStatusFixtureToScope(fixture, (goalId) => (statusGeneration.get(goalId) ?? "active") === "stopped"); } else { fixture.goal_projection = { schema_version: "loopx_goal_projection_scope_v0", diff --git a/examples/personal-workspace-browser/progressive-loading.mjs b/examples/personal-workspace-browser/progressive-loading.mjs new file mode 100644 index 0000000000..1d3c869a69 --- /dev/null +++ b/examples/personal-workspace-browser/progressive-loading.mjs @@ -0,0 +1,86 @@ +import { openWorkspacePage } from "./scenario-context.mjs"; + +// One Goal's lifecycle action must not send the rest of the workspace back to +// the loading lane. The directory entry is the cheap signal for "this Goal did +// not move", so only the touched Goal is re-read. +export const progressiveLoadingScenario = { + id: "progressive-loading", + async run({ browser, collectCoverage, url }) { + const context = await openWorkspacePage(browser, url, { + apiOptions: { progressiveWorkspace: true }, + collectCoverage, + }); + const { api, coverageEntries, errors: pageErrors, page } = context; + // Only the product's own reads are asserted: the development server may + // refuse an asset it considers outside its root, which is a harness path + // question rather than a client failure. + const failedResponses = []; + page.on("response", (response) => { + if (response.status() >= 400 && response.url().includes("/api/")) { + failedResponses.push(`${response.status()} ${response.url()}`); + } + }); + try { + await page.waitForFunction( + () => document.querySelectorAll(".personal-home-lane[aria-live]").length === 0, + null, + { timeout: 60_000 }, + ); + if (api.workspaceDirectoryRequests === 0) throw new Error("Progressive loading never read the workspace directory"); + const cardTitles = () => page.locator(".personal-home-lanes .personal-home-goal-card strong").allInnerTexts(); + const peers = (await cardTitles()).filter((title) => title !== "Product Release"); + if (peers.length === 0) throw new Error("Progressive loading rendered no peer Goal cards"); + const loadingCards = () => page.locator('.personal-home-goal-card[data-goal-state="loading"]').count(); + if (await loadingCards()) throw new Error("The workspace settled with a Goal still in its loading state"); + + api.goalStatusRequests.length = 0; + await page.getByRole("button", { name: "停止 Product Release", exact: true }).click(); + await page.waitForTimeout(3_000); + + const rereadPeers = [...new Set(api.goalStatusRequests)].filter((goalId) => goalId !== "product-release"); + if (rereadPeers.length) { + throw new Error(`Pausing one Goal re-read its peers: ${JSON.stringify(rereadPeers)}`); + } + if (await loadingCards()) throw new Error("Pausing one Goal sent the workspace back to its loading lane"); + const afterStop = await cardTitles(); + for (const title of peers) { + if (!afterStop.includes(title)) throw new Error(`Pausing one Goal dropped the peer card ${title}`); + } + const stoppedDirectory = page.locator(".personal-stopped-goals"); + if (!await stoppedDirectory.evaluate((node) => node.open)) { + await stoppedDirectory.locator("summary").click(); + } + + api.goalStatusRequests.length = 0; + await page.getByRole("button", { name: "恢复 Product Release", exact: true }).click(); + await page.getByText("确认执行", { exact: true }).waitFor({ state: "visible" }); + await page.locator('[data-action-review="review"]').filter({ hasText: "恢复自动调度前需要确认" }).waitFor({ state: "visible" }); + await page.getByRole("button", { name: "恢复 Goal", exact: true }).click(); + await page.waitForTimeout(3_000); + + const rereadPeersAfterResume = [...new Set(api.goalStatusRequests)] + .filter((goalId) => goalId !== "product-release"); + if (rereadPeersAfterResume.length) { + throw new Error(`Resuming one Goal re-read its peers: ${JSON.stringify(rereadPeersAfterResume)}`); + } + if (await loadingCards()) throw new Error("Resuming one Goal sent the workspace back to its loading lane"); + const resumed = await cardTitles(); + for (const title of peers) { + if (!resumed.includes(title)) throw new Error(`Resuming one Goal dropped the peer card ${title}`); + } + if (!resumed.includes("Product Release")) throw new Error("The resumed Goal did not return to the workspace board"); + + if (failedResponses.length) throw new Error(`Progressive reconciliation failed requests: ${failedResponses.join(" | ")}`); + // The harness collects uncaught page errors and console errors; a + // dev-server resource status is not a client-side exception. + const scriptErrors = pageErrors.filter((message) => !message.startsWith("Failed to load resource")); + if (scriptErrors.length) throw new Error(`Progressive reconciliation raised page errors: ${scriptErrors.join(" | ")}`); + return { + coverageEntries, + note: "progressive loading reads the workspace directory once and then one Goal at a time; a single Goal's pause and resume re-read no peer, never re-enters the loading lane and keeps every peer card on the board", + }; + } finally { + coverageEntries.push(...await context.close()); + } + }, +};