diff --git a/apps/controller/src/console/agents/detail.mjs b/apps/controller/src/console/agents/detail.mjs index 696c06b9b8..f80025c055 100644 --- a/apps/controller/src/console/agents/detail.mjs +++ b/apps/controller/src/console/agents/detail.mjs @@ -452,6 +452,13 @@ const DEPLOYMENT_OUTCOME_TONES = { failed: "danger", }; +// A read failure that a later read can clear: no answer (a network error), a timeout, a rate +// limit or a server error. A refusal or a missing record is an answer, and polling stops on it. +function transientReadError(error) { + const status = error?.status; + return !(status >= 400 && status < 500) || status === 408 || status === 429; +} + // A recovery link opens a version tab far below the banner it sits in. Only the URL changed and // the page stayed at the top, so the link looked dead (finding 1586). The page the link opens, // switched in place, rebuilt or restored for Back, brings that tab into view and focuses it. @@ -533,6 +540,9 @@ function createDeploymentStatusPanel(context, path, revision, onAgentChange, onS // Tallest the details have been since a deploy was seen pending; see render(). let reservedHeight = null; let pollTimer = null; + // The last record read. A transient read failure (a network error, a timeout, a 5xx) does not + // end a pending deploy, so polling goes on from it (finding 2085). + let lastRecord = null; // A poll that fired while the view was retained stopped; restoring the view re-arms it. context.onResume?.(() => { @@ -541,14 +551,18 @@ function createDeploymentStatusPanel(context, path, revision, onAgentChange, onS } }); - // Queued and running records are reread until they record a result or a read fails. + // Queued and running records are reread until they record a result or a read is refused. function schedulePoll() { clearTimeout(pollTimer); pollTimer = null; + const record = state.error + ? transientReadError(state.error) + ? lastRecord + : null + : state.status; if ( - state.error || - (!PENDING_DEPLOYMENT_STATUSES.has(state.status?.status) && - !PENDING_DEPLOYMENT_STATUSES.has(state.status?.cleanup?.status)) + !PENDING_DEPLOYMENT_STATUSES.has(record?.status) && + !PENDING_DEPLOYMENT_STATUSES.has(record?.cleanup?.status) ) { return; } @@ -567,7 +581,8 @@ function createDeploymentStatusPanel(context, path, revision, onAgentChange, onS } clearTimeout(pollTimer); pollTimer = null; - const previousStatus = state.status?.status ?? null; + // After a failed read, the record before it. + const previousStatus = lastRecord?.status ?? null; state.loading = true; state.polling = poll; state.error = null; @@ -592,6 +607,7 @@ function createDeploymentStatusPanel(context, path, revision, onAgentChange, onS } if (statusResult.status === "fulfilled") { state.status = statusResult.value; + lastRecord = statusResult.value; } else { state.status = null; state.error = statusResult.reason; @@ -626,6 +642,8 @@ function createDeploymentStatusPanel(context, path, revision, onAgentChange, onS state.error !== null, state.status?.error?.code ?? null, state.status, + state.overviewError, + state.error !== null && transientReadError(state.error), ); if ( state.refreshQueued || @@ -1907,6 +1925,11 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null let latestDeploymentErrorCode = null; let latestDeploymentErrorMessage = null; let latestDeploymentRecord = null; + // The newest version's status from its last record read; a failed read keeps it, so one + // failure does not relabel a deploying version Historical (finding 2085). + let latestRecordedStatus = null; + // Set when the Agent reread after a completed deployment failed, until an Agent read lands. + let agentRereadFailed = false; let versionRecord = null; const revisionsPromise = readRevisionPage(request, path); // Every revision summary listed so far, from the newest page and from older pages the viewer @@ -1930,7 +1953,9 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null ? "Current version · read-only admitted snapshot." : deployingRevision()?.id === selected ? "Deploying version · read-only admitted snapshot. It becomes the current version only if its deployment completes." - : "Historical version · read-only admitted snapshot. Browsing it does not change the current version."; + : deployedRevision()?.id === selected + ? "Deployed version · read-only admitted snapshot. Its deployment completed, but the current version could not be refreshed." + : "Historical version · read-only admitted snapshot. Browsing it does not change the current version."; } // The newest version while its deployment is queued or running and it is not yet current. Right @@ -1940,7 +1965,24 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null return latest && latest.id !== currentRevisionId && activityRevisionId === latest.id && - PENDING_DEPLOYMENT_STATUSES.has(latestDeploymentStatus) + PENDING_DEPLOYMENT_STATUSES.has(latestRecordedStatus) + ? latest + : null; + } + + // The newest version once its deployment completed, while the Agent reread that would make it + // current failed. It is not Deploying beside "Completed" (finding 2085), nor Historical: the + // page cannot yet tell. Only that failed reread says so; a stopped Agent's last version, say, + // also has a completed record and no current version, and stays Historical, as does one under + // a known newer current version the viewer cannot read. + function deployedRevision() { + const latest = visibleRevisions[0]; + return latest && + latest.id !== currentRevisionId && + activityRevisionId === latest.id && + latestRecordedStatus === "succeeded" && + agentRereadFailed && + !revisionHidden(currentRevisionId) ? latest : null; } @@ -2161,6 +2203,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null showDeleting(); return; } + agentRereadFailed = false; const revisionChanged = freshAgent.activeRevisionId !== currentRevisionId; const workspaceAvailabilityChanged = Boolean(freshAgent.activeRevisionId) !== Boolean(currentRevisionId); @@ -2574,6 +2617,8 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null // The version's record panel no longer gets this panel's reads. versionRecord?.release(); latestDeploymentStatus = null; + latestRecordedStatus = null; + agentRereadFailed = false; latestDeploymentRecord = null; latestDeploymentError = false; latestDeploymentErrorCode = null; @@ -2605,12 +2650,19 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null { status: "fulfilled", value: freshRevisions }, snapshot, ), - (status, unavailable, errorCode, record) => { + (status, unavailable, errorCode, record, overviewUnavailable, transientFailure) => { if (activityRevisionId !== mostRecent.id) { return; } showPanel(nextPanel, status === "failed"); latestDeploymentStatus = status; + // A refused or missing record ends the wait, so it no longer labels the version. + if (record || !transientFailure) { + latestRecordedStatus = status; + } + if (overviewUnavailable) { + agentRereadFailed = true; + } latestDeploymentError = unavailable; latestDeploymentErrorCode = errorCode; latestDeploymentErrorMessage = record?.error?.message || null; @@ -2630,8 +2682,9 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null } renderLatestDeployment(); // A success is shown once the Agent reread makes the version current; until then - // the notice keeps "Deploying" rather than flash "Historical". - if (status !== "succeeded") { + // the notice keeps "Deploying" rather than flash "Historical". A reread that failed + // leaves the version labelled from its record (finding 2085). + if (status !== "succeeded" || overviewUnavailable) { renderVersionNotice(); } refreshDeployControls(); diff --git a/docs/flows/platform-console/agent-editing.md b/docs/flows/platform-console/agent-editing.md index 24f2d71f01..1b6b4e47eb 100644 --- a/docs/flows/platform-console/agent-editing.md +++ b/docs/flows/platform-console/agent-editing.md @@ -87,7 +87,7 @@ read error or denial. **Open version draft** (`deploy` only) and **Deploy again* message) need `deploy` and a successful Configuration read; Deploy again focuses **Deploy new version**. Credentials recovery needs `update`. Hidden or unlisted current versions suppress recovery; viewed versions show results. Activity polls every -`DEPLOYMENT_POLL_MS` until a result or read error; a new result also rereads selection. **Refresh deployment** rereads +`DEPLOYMENT_POLL_MS`, through transient read errors, until a result, denial, or missing record; a new result also rereads selection. **Refresh deployment** rereads activity and selection, after any poll in flight. Polls patch in place (`patchChildren`), so an unchanged poll writes nothing. A pending deploy lists its result rows before the first attempt; the details keep their tallest height until Refresh after it settles. **Current observations** requests timestamped diff --git a/docs/guides/console/agent-details.md b/docs/guides/console/agent-details.md index 9cb0bca53a..b9109597fb 100644 --- a/docs/guides/console/agent-details.md +++ b/docs/guides/console/agent-details.md @@ -56,7 +56,7 @@ check time. A failed startup model check may also show its **Cause**, such as the provider reporting a rate limit or the check process exiting with an error ([causes](../../reference/agents/deployment.md#model-check-failure-cause)). Plugin warnings describe that attempt. An unavailable record has unknown status. While the record is `queued` or `running`, the panel rereads it -every few seconds and stops at `succeeded`, `failed`, or a read error. +every few seconds, through outages, until `succeeded`, `failed`, a denial, or a missing record. **Refresh deployment** rereads it, the selected version, and its deployment record without retrying work. diff --git a/scripts/console-storybook/pages-agent-detail.stories.mjs b/scripts/console-storybook/pages-agent-detail.stories.mjs index 72571f5a69..bbf7f4d8f5 100644 --- a/scripts/console-storybook/pages-agent-detail.stories.mjs +++ b/scripts/console-storybook/pages-agent-detail.stories.mjs @@ -48,6 +48,8 @@ export const DeploymentDeferred = story("deploymentDeferred"); export const DeploymentRetrying = story("deploymentRetrying"); export const DeploymentRunning = story("deploymentRunning"); export const DeploymentRunningActivates = story("deploymentRunningActivates"); +export const DeploymentCompletesRereadFails = story("deploymentCompletesRereadFails"); +export const DeploymentStatusReadRecovers = story("deploymentStatusReadRecovers"); export const DeploymentActivityLoading = story("deploymentActivityLoading"); export const CurrentVersionDuringDeployment = story("currentVersionDuringDeployment"); export const DeploymentFailed = story("deploymentFailed"); diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index 7f470a9784..36cd7820b9 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -4014,6 +4014,45 @@ export const scenarios = { 'Within about 5 s the notice begins "Current version" and Selected version reads v7.', ], }, + deploymentCompletesRereadFails: { + group: "Pages/Agent detail", + name: "New version completes, Agent reread fails", + path: `${candidateVersion}&tab=configuration`, + deployed: true, + candidateDeploymentStatus: "running", + candidateActivates: true, + // The page's own Agent read answers; the reread after the deployment completes fails. + rules: [{ suffix: "/agents/agt_00000000-0000-4000-8000-000000000001", skip: 1, status: 503 }], + description: + "v7's deployment completes, but the Agent reread that would make it current fails. The notice says the deployment completed and that the current version could not be refreshed, rather than Deploying beside Completed. The deployment and the failure are simulated.", + steps: [ + 'On arrival the notice begins "Deploying version" and Selected version reads v6.', + 'Within about 5 s Latest visible deployment reads "v7 · Completed", the page says the current version and version history could not be refreshed, and the notice reads "Deployed version · read-only admitted snapshot. Its deployment completed, but the current version could not be refreshed."', + ], + }, + deploymentStatusReadRecovers: { + group: "Pages/Agent detail", + name: "New version in progress, one status read fails", + path: `${candidateVersion}&tab=configuration`, + deployed: true, + candidateDeploymentStatus: "running", + candidateActivates: true, + // The first poll after arrival fails once; the next one records success. + rules: [ + { + suffix: "/deployments/rev_00000000-0000-4000-8000-000000000007", + skip: 1, + once: true, + status: 503, + }, + ], + description: + "One deployment status read fails while v7 is deploying. The notice keeps Deploying version rather than turning Historical, polling continues, and the next read records success and makes v7 current. The deployment, failure and activation are simulated.", + steps: [ + 'On arrival the notice begins "Deploying version". Within about 5 s Latest visible deployment reads "v7 · Unavailable" and the notice still begins "Deploying version".', + 'Within about 10 s the notice begins "Current version" and Selected version reads v7, with no "Historical version" in between.', + ], + }, deploymentDeferred: { group: "Pages/Agent detail", name: "Deployment waiting for runtime", diff --git a/tests/browser/console-agent-deploy-again.test.mjs b/tests/browser/console-agent-deploy-again.test.mjs index 84de9302ae..af5b949b88 100644 --- a/tests/browser/console-agent-deploy-again.test.mjs +++ b/tests/browser/console-agent-deploy-again.test.mjs @@ -461,48 +461,80 @@ test("Open version draft waits for the Configuration read to succeed", async (t) // While v2 deploys, v1 is still the current version. Right after Deploy new version the page // selected v2 and called it "Historical" beside "Selected version v1" until the deployment -// finished (finding 2065). Its notice says it is deploying until it becomes current. -async function deployingVersion(t, name) { +// finished (finding 2065). Its notice says it is deploying until it becomes current. `record` +// drives the newest deployment's answer: its status, or an error while `fail` holds an HTTP +// status (503, 403); `agentFail` answers the Agent read with a 503. `recordReads` counts the +// newest deployment's reads. With `older`, a version admitted before the deploying one is +// neither current nor deploying. +const failure = (status) => ({ + status, + contentType: "application/json", + body: JSON.stringify({ + error: + status === 403 + ? { code: "FORBIDDEN", message: "The exact platform operation was not authorized." } + : { code: "UNAVAILABLE", message: "Try again." }, + meta: { requestId: "req_test_failure" }, + }), +}); +const UNAVAILABLE = failure(503); + +async function deployingVersion(t, name, { status = "running", older = false } = {}) { const { fixture, namespace } = await createReadyConsoleNamespace(t, name); const agent = await fixture.createAgent(namespace.id, `${name} Agent`, nativeValues("v1")); const { revision: current } = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const previous = older ? await fixture.deployAgent(namespace.id, agent.id) : null; const next = await fixture.deployAgent(namespace.id, agent.id); const { page } = await newPage(t, fixture, { browser }); - const record = { status: "running" }; + const record = { status, fail: false, agentFail: false, recordReads: 0 }; await page.route( - `${fixture.origin}/namespaces/${namespace.id}/agents/${agent.id}/deployments/${next.id}`, + (url) => url.pathname === `/namespaces/${namespace.id}/agents/${agent.id}`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - deploymentId: next.id, - namespaceId: namespace.id, - agentId: agent.id, - status: record.status, - error: - record.status === "failed" - ? persistedDeploymentError("DEPENDENCY_UNAVAILABLE") - : null, - warnings: [], - progress: null, - }, - meta: { requestId: "req_test_deploying_label" }, - }), - }), + record.agentFail && route.request().method() === "GET" + ? route.fulfill(UNAVAILABLE) + : route.fallback(), + ); + await page.route( + `${fixture.origin}/namespaces/${namespace.id}/agents/${agent.id}/deployments/${next.id}`, + (route) => { + record.recordReads += 1; + return record.fail + ? route.fulfill(failure(record.fail)) + : route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + deploymentId: next.id, + namespaceId: namespace.id, + agentId: agent.id, + status: record.status, + error: + record.status === "failed" + ? persistedDeploymentError("DEPENDENCY_UNAVAILABLE") + : null, + warnings: [], + progress: null, + }, + meta: { requestId: "req_test_deploying_label" }, + }), + }); + }, ); await page.clock.install({ time: new Date("2026-10-20T12:00:00Z") }); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, next.id, "configuration")); const summary = page.getByRole("region", { name: "Agent state at a glance" }); - await summary.getByText("v2 · In progress", { exact: true }).waitFor(); - await page - .getByText( - "Deploying version · read-only admitted snapshot. It becomes the current version only if its deployment completes.", - { exact: true }, - ) - .waitFor(); - return { fixture, namespace, agent, current, next, page, record, summary }; + const outcome = { queued: "Queued", running: "In progress", succeeded: "Completed" }[status]; + await summary.getByText(`v${next.revision} · ${outcome}`, { exact: true }).waitFor(); + if (status !== "succeeded") { + await page + .getByText( + "Deploying version · read-only admitted snapshot. It becomes the current version only if its deployment completes.", + { exact: true }, + ) + .waitFor(); + } + return { fixture, namespace, agent, current, previous, next, page, record, summary }; } // Every text the version notice shows, in order, from now on. @@ -564,3 +596,160 @@ test("A deploying version's parked Configuration tab says Historical once the de await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); assert.equal(await page.getByText(/^Deploying version/).count(), 0); }); + +// A queued deployment is deploying too, and only the deploying version is: an older version +// browsed meanwhile is Historical (finding 2088). +test("A queued version is labelled Deploying, and an older version browsed meanwhile Historical", async (t) => { + const { fixture, namespace, agent, previous, page } = await deployingVersion(t, "Queued label", { + status: "queued", + older: true, + }); + await page.goto(detailUrl(fixture, namespace.id, agent.id, previous.id, "configuration").href); + await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); + assert.equal(await page.getByText(/^Deploying version/).count(), 0); + await page.goBack(); + await page.getByText(/^Deploying version · read-only admitted snapshot/).waitFor(); +}); + +// A completed deployment whose Agent reread failed kept "Deploying" beside "v2 · Completed" +// (finding 2085). The record says the deployment completed, so the notice says so. +test("A completed deployment whose Agent reread fails is labelled Deployed, not Deploying", async (t) => { + const { fixture, namespace, agent, current, next, page, record, summary } = + await deployingVersion(t, "Deployed label"); + await watchNotice(page); + await fixture.activateRevision(namespace.id, agent.id, next.id, current.id); + record.status = "succeeded"; + record.agentFail = true; + await page.clock.runFor(DEPLOYMENT_POLL_MS); + await page + .getByText( + "Current version and version history could not be refreshed. Use the page Refresh control to retry.", + ) + .waitFor(); + await summary.getByText("v2 · Completed", { exact: true }).waitFor(); + await page + .getByText( + "Deployed version · read-only admitted snapshot. Its deployment completed, but the current version could not be refreshed.", + { exact: true }, + ) + .waitFor(); + const labels = (await page.evaluate(() => globalThis.noticeTexts)).map( + (text) => text.split(" ·")[0], + ); + assert.equal(labels.includes("Historical version"), false, labels.join(" → ")); +}); + +// One failed status read while v2 still deployed flipped the notice to Historical (the finding +// 2065 mislabel) and stopped polling, so it stayed there after the API recovered (finding 2085). +// The notice keeps the last record's label, polling goes on, and a completion read right after +// the failure still rereads the Agent. +test("One failed status read keeps a deploying version Deploying and polling", async (t) => { + const { fixture, namespace, agent, current, next, page, record, summary } = + await deployingVersion(t, "Status blip"); + await watchNotice(page); + record.fail = 503; + await page.clock.runFor(DEPLOYMENT_POLL_MS); + await summary.getByText("v2 · Unavailable", { exact: true }).waitFor(); + assert.equal(await page.getByText(/^Deploying version · /).count(), 1); + // The next poll still comes, and it finds the deployment completed. + record.fail = false; + await fixture.activateRevision(namespace.id, agent.id, next.id, current.id); + record.status = "succeeded"; + await page.clock.runFor(DEPLOYMENT_POLL_MS); + await summary.getByText("v2 · Completed", { exact: true }).waitFor(); + await page.getByText(/^Current version · read-only admitted snapshot/).waitFor(); + const labels = (await page.evaluate(() => globalThis.noticeTexts)).map( + (text) => text.split(" ·")[0], + ); + assert.equal(labels.includes("Historical version"), false, labels.join(" → ")); +}); + +// A completed record is not by itself a failed reread: the newest version whose record reads +// completed while the Agent still names v1 current (activation not yet seen, or a stopped Agent +// with no current version) stays Historical, not "Deployed" (internal review of finding 2085). +test("A completed record with no failed Agent reread leaves the newest version Historical", async (t) => { + const { page } = await deployingVersion(t, "Completed unseen", { status: "succeeded" }); + await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); + // The tab is built again from what the page knows now. + await page.getByRole("tab", { name: "Plugins", exact: true }).click(); + await page.getByRole("tab", { name: "Plugins", exact: true, selected: true }).waitFor(); + await page.getByRole("tab", { name: "Configuration", exact: true }).click(); + await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); + assert.equal(await page.getByText(/^Deployed version/).count(), 0); +}); + +// v3 is current but this reader cannot read it, so completed v2 is the newest version listed and +// already known to be older than the current one. A failed Agent reread on Refresh deployment +// changes nothing about that: v2 stays Historical, not Deployed (internal review of finding 2085). +test("A failed reread leaves the newest readable version Historical under a hidden current one", async (t) => { + const { fixture, namespace } = await createReadyConsoleNamespace(t, "Hidden current"); + const agent = await fixture.createAgent(namespace.id, "Hidden current Agent", nativeValues("v1")); + const { revision: first } = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const next = await fixture.deployAgent(namespace.id, agent.id); + const hidden = await fixture.deployAgent(namespace.id, agent.id); + await fixture.activateRevision(namespace.id, agent.id, hidden.id, first.id); + fixture.policy.restrictions.push({ + id: `deny-read-${hidden.id}`, + namespaceId: namespace.id, + resourceKind: "agent_revision", + resourceId: hidden.id, + action: "read", + effect: "deny", + }); + const { page } = await newPage(t, fixture, { browser }); + let agentFail = false; + await page.route( + (url) => url.pathname === `/namespaces/${namespace.id}/agents/${agent.id}`, + (route) => + agentFail && route.request().method() === "GET" + ? route.fulfill(UNAVAILABLE) + : route.fallback(), + ); + await page.route( + `${fixture.origin}/namespaces/${namespace.id}/agents/${agent.id}/deployments/${next.id}`, + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + deploymentId: next.id, + namespaceId: namespace.id, + agentId: agent.id, + status: "succeeded", + error: null, + warnings: [], + progress: null, + }, + meta: { requestId: "req_test_hidden_current" }, + }), + }), + ); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, next.id, "configuration")); + const summary = page.getByRole("region", { name: "Agent state at a glance" }); + await summary.getByText("Newer version hidden", { exact: true }).waitFor(); + await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); + agentFail = true; + await page.getByRole("button", { name: "Refresh deployment" }).click(); + await page + .getByText( + "Current version and version history could not be refreshed. Use the page Refresh control to retry.", + ) + .waitFor(); + await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); + assert.equal(await page.getByText(/^Deployed version/).count(), 0); +}); + +// A refused record read ends the wait: polling stops, and the version is no longer labelled +// Deploying from a record the page can no longer follow (internal review of finding 2085). +test("A refused status read ends the wait and the Deploying label", async (t) => { + const { page, record, summary } = await deployingVersion(t, "Status refused"); + record.fail = 403; + await page.clock.runFor(DEPLOYMENT_POLL_MS); + await summary.getByText("v2 · Unavailable", { exact: true }).waitFor(); + await page.getByText(/^Historical version · read-only admitted snapshot/).waitFor(); + const refused = record.recordReads; + record.fail = false; + await page.clock.runFor(DEPLOYMENT_POLL_MS * 3); + assert.equal(record.recordReads, refused); +});