From 5505d7aa92366b1846b55ae525ed83e7a92a99fa Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 13:28:19 +0900 Subject: [PATCH 1/6] fix(ask): retire answers across authorization changes --- AGENTS.md | 9 ++++++ docs/product-technical-gap-baseline.md | 36 ++++++++++++++++++++++ frontend/src/App.tsx | 37 ++++++++++++++++------- frontend/src/AskAgentPanel.test.tsx | 42 +++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3af4169bc..e378e1c7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -422,3 +422,12 @@ columns). Do not silently rewrite either historical form. The SHACL shapes graph (`docs/ontology/lineageweave-kg-shapes.ttl`) is the closed-world data-validation boundary for DB-to-RDF projections and is published beside the ontology. + +## Authorization-sensitive asynchronous UI + +Bind imperative result, error, and loading completion to the originating +component authorization lifecycle. Token equality alone cannot distinguish +retired A from current A after A → B → A. Clear prior questions, answers, and +evidence selection when credentials change, and verify that a retired success +or failure cannot end the current request's loading state. Keep behavioral +regressions synthetic; UI retirement does not itself cancel a server-side job. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..99ed7f934 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -932,3 +932,39 @@ The ONET rows stacked into base branches (#743/#745/#746/#740/#732) reached `main` together through the #759 promotion; their per-base merge records are historical evidence only. The job-architecture artifact ship originally via #749 is now re-verified on `main` from the promotion. + + +## Ask authorization lifecycle repair (2026-09-07) + +A pending Ask response could enter a later authorization lifecycle after +A → B → A. Deferred success displayed the retired answer; deferred HTTP 403 +displayed the retired transport error. Both were assertion failures, not test +deadlines, in the initial two-case reproduction. + +The component now clears question, cutoff, answer, error, external-verification +selection, loading, and evidence selection when its credential changes. It reuses +the existing component-generation pattern from Customer Master: request success, +failure, and loading completion require both the current credential and the +originating authorization generation. Cleanup retires the generation on token +change or unmount. No global session service, token persistence, API, or schema +is introduced. Server ABAC and ADR 0216's cutoff evidence remain authoritative. + +The regression cases also start a new request after re-entry and require the +retired success/failure to leave that request loading until its own answer arrives. +This is UI admission evidence; it does not establish cancellation of a running +server job or the existing client poll loop. Those transport lifetimes remain a +separate gap, as does current-error copy that can expose transport details. + +The repair is based on protected main `83eba56149eb802cd63642c507c324c9976ec78e` +and is independent of the unmerged translation-ledger foundation in PR #929/#932. +The initial experiment used the PR #932 worktree, then moved only the Ask diff to +an isolated main-based branch; the translation worktree was restored clean. +No real source records, new containers, or deployment were used. + +Validation on the main-based candidate: 2/3 Ask tests passed; the deferred-success +case exceeded its unchanged five-second deadline. The expanded deferred-denial +case and existing cutoff/public-evidence case passed. The earlier narrower +two-case repair run passed 3/3 before adding the current-request loading check; +it is not substituted for the final run. Lint and TypeScript/production build +passed; the existing large-chunk warning remains. The change stays Draft pending +complete verification, independent review, and real-account acceptance. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3fb6c796..9f6c4e237 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5052,6 +5052,21 @@ export function AskAgentPanel({ const [asking, setAsking] = useState(false); const [verifyExternal, setVerifyExternal] = useState(false); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); + const authGeneration = useRef(0); + const currentAccessTokenRef = useRef(accessToken); + currentAccessTokenRef.current = accessToken; + const [inputAccessToken, setInputAccessToken] = useState(accessToken); + if (inputAccessToken !== accessToken) { + setInputAccessToken(accessToken); + setQuestion(""); + setKnowledgeCutoff(""); + setAnswer(null); + setError(null); + setAsking(false); + setVerifyExternal(false); + setEvidenceLayerPostId(null); + } + useEffect(() => () => { authGeneration.current += 1; }, [accessToken]); const now = new Date(); const localKnowledgeCutoffMax = new Date( now.getTime() - now.getTimezoneOffset() * 60_000, @@ -5068,22 +5083,22 @@ export function AskAgentPanel({ setError(t("Enter a valid knowledge cutoff, then ask again.")); return; } + const requestAuthGeneration = authGeneration.current; + const requestAccessToken = accessToken; setAsking(true); setError(null); try { - setAnswer( - await askAgent( - accessToken, - normalized, - verifyExternal, - cutoff, - ), - ); + const response = await askAgent(accessToken, normalized, verifyExternal, cutoff); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setAnswer(response); + } } catch (err) { - setAnswer(null); - setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { + setAnswer(null); + setError(orchestratorUnavailableMessage(err, t("Ask Agent"))); + } } finally { - setAsking(false); + if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) setAsking(false); } } diff --git a/frontend/src/AskAgentPanel.test.tsx b/frontend/src/AskAgentPanel.test.tsx index 5eeb0549c..9a6fec095 100644 --- a/frontend/src/AskAgentPanel.test.tsx +++ b/frontend/src/AskAgentPanel.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AskAgentPanel } from "./App"; @@ -8,6 +8,46 @@ describe("AskAgentPanel public verification", () => { vi.unstubAllGlobals(); }); + it.each([200, 403])("discards a retired %s response after credential re-entry", async (status) => { + let finishRetired!: (response: Response) => void; + let finishCurrent!: (response: Response) => void; + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ ask_job_id: "retired-job", job_status_code: "queued" }), { status: 202 })) + .mockImplementationOnce(() => new Promise((resolve) => { finishRetired = resolve; })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ask_job_id: "current-job", job_status_code: "queued" }), { status: 202 })) + .mockImplementationOnce(() => new Promise((resolve) => { finishCurrent = resolve; })); + vi.stubGlobal("fetch", fetchMock); + const onOpenPost = vi.fn(); + const { rerender, container } = render(); + fireEvent.change(screen.getByLabelText("Ask a question"), { target: { value: "Retired question" } }); + fireEvent.click(screen.getByRole("button", { name: "Ask" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + rerender(); + rerender(); + expect(screen.getByLabelText("Ask a question")).toHaveValue(""); + fireEvent.change(screen.getByLabelText("Ask a question"), { target: { value: "Current question" } }); + fireEvent.click(screen.getByRole("button", { name: "Ask" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + await act(async () => { + finishRetired(new Response(JSON.stringify({ + ask_job_id: "retired-job", job_status_code: "succeeded", + answer: { answer_text: "Retired answer", cited_post_ids: [], cited_posts: [], + cited_post_evidence: [], source_post_ids: [], external_claims: [], limitations: [] }, + }), { status })); + }); + expect(screen.queryByText("Retired answer")).not.toBeInTheDocument(); + expect(container.querySelector(".error")).toBeNull(); + expect(screen.getByRole("button", { name: "Asking..." })).toBeDisabled(); + await act(async () => { + finishCurrent(new Response(JSON.stringify({ + ask_job_id: "current-job", job_status_code: "succeeded", + answer: { answer_text: "Current answer", cited_post_ids: [], source_post_ids: [] }, + }), { status: 200 })); + }); + expect(screen.getByText("Current answer")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Ask" })).toBeEnabled(); + }); + it("keeps public verification separate and renders cutoff provenance", async () => { const fetchMock = vi .fn() From db541bbe04689c544778b2dea7d91548b4b8bdc0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 13:33:41 +0900 Subject: [PATCH 2/6] fix(ask): abort retired client polling --- AGENTS.md | 6 ++++ docs/product-technical-gap-baseline.md | 31 +++++++++++++++++++++ frontend/src/App.tsx | 10 +++++-- frontend/src/AskAgentPanel.test.tsx | 15 ++++++++++ frontend/src/api.test.ts | 38 ++++++++++++++++++++++++++ frontend/src/api.ts | 24 ++++++++++++---- 6 files changed, 117 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e378e1c7e..024c31257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -431,3 +431,9 @@ retired A from current A after A → B → A. Clear prior questions, answers, an evidence selection when credentials change, and verify that a retired success or failure cannot end the current request's loading state. Keep behavioral regressions synthetic; UI retirement does not itself cancel a server-side job. + +Pass a native AbortSignal through client polling and fetch when retiring an +authenticated screen. Preserve the abort reason instead of reporting a network +failure, and clear delay timers/listeners. Result-admission guards remain necessary +for already-resolved work. Verify both transport cancellation and A → B → A +state admission; neither establishes server-job cancellation. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 99ed7f934..cb169893b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -968,3 +968,34 @@ two-case repair run passed 3/3 before adding the current-request loading check; it is not substituted for the final run. Lint and TypeScript/production build passed; the existing large-chunk warning remains. The change stays Draft pending complete verification, independent review, and real-account acceptance. + + +### Ask client transport retirement follow-up (2026-09-07) + +The UI guard alone did not retire transport: cancellation during a running-job +poll delay still caused a third HTTP request, and credential/unmount cleanup +supplied no AbortSignal. Three behavioral assertions reproduced those gaps. + +The existing backendFetch RequestInit now carries a native AbortSignal from +AskAgentPanel through job submission and status reads. Component cleanup aborts +its request controller. The shared polling loop checks cancellation before +submission, before each status request, and after a returned status; delay abort +clears its timer, while normal delay completion removes the listener. The fetch +error boundary preserves the native cancellation reason. Existing generation +checks still reject retired results, errors, and loading completions. + +This closes the client-poll-loop gap recorded above. It does not add a server +job cancellation endpoint or change the existing execution/deadline policy. +Current-error copy can still expose transport details and remains a separate +verified gap. No provider SDK, session store, or dependency was introduced. + +Reference: MDN contributors. (2026, August 27). *AbortSignal: throwIfAborted() +method*. MDN Web Docs. +https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted + +Final focused validation passed 15/15 tests across the Ask and API files in +3.04 s, including the previously timed-out expanded re-entry success case, +pre-submission abort, in-flight fetch abort, and delay cancellation without another +request. Lint and TypeScript/production build passed with the existing chunk +warning. This supersedes the earlier focused timeout result, not the outstanding +full-suite, hosted, real-account, or page-latency acceptance requirements. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f6c4e237..2f57b202a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5053,6 +5053,7 @@ export function AskAgentPanel({ const [verifyExternal, setVerifyExternal] = useState(false); const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); const authGeneration = useRef(0); + const requestController = useRef(null); const currentAccessTokenRef = useRef(accessToken); currentAccessTokenRef.current = accessToken; const [inputAccessToken, setInputAccessToken] = useState(accessToken); @@ -5066,7 +5067,10 @@ export function AskAgentPanel({ setVerifyExternal(false); setEvidenceLayerPostId(null); } - useEffect(() => () => { authGeneration.current += 1; }, [accessToken]); + useEffect(() => () => { + authGeneration.current += 1; + requestController.current?.abort(); + }, [accessToken]); const now = new Date(); const localKnowledgeCutoffMax = new Date( now.getTime() - now.getTimezoneOffset() * 60_000, @@ -5083,12 +5087,14 @@ export function AskAgentPanel({ setError(t("Enter a valid knowledge cutoff, then ask again.")); return; } + const controller = new AbortController(); + requestController.current = controller; const requestAuthGeneration = authGeneration.current; const requestAccessToken = accessToken; setAsking(true); setError(null); try { - const response = await askAgent(accessToken, normalized, verifyExternal, cutoff); + const response = await askAgent(accessToken, normalized, verifyExternal, cutoff, controller.signal); if (requestAccessToken === currentAccessTokenRef.current && requestAuthGeneration === authGeneration.current) { setAnswer(response); } diff --git a/frontend/src/AskAgentPanel.test.tsx b/frontend/src/AskAgentPanel.test.tsx index 9a6fec095..87fcf61cb 100644 --- a/frontend/src/AskAgentPanel.test.tsx +++ b/frontend/src/AskAgentPanel.test.tsx @@ -8,6 +8,21 @@ describe("AskAgentPanel public verification", () => { vi.unstubAllGlobals(); }); + it.each(["credential", "unmount"])("aborts transport on %s retirement", async (retirement) => { + const fetchMock = vi.fn().mockImplementation(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetchMock); + const props = { onOpenPost: vi.fn() }; + const { rerender, unmount } = render(); + fireEvent.change(screen.getByLabelText("Ask a question"), { target: { value: "Question" } }); + fireEvent.click(screen.getByRole("button", { name: "Ask" })); + const signal = fetchMock.mock.calls[0][1].signal; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + if (retirement === "unmount") unmount(); + else rerender(); + expect(signal.aborted).toBe(true); + }); + it.each([200, 403])("discards a retired %s response after credential re-entry", async (status) => { let finishRetired!: (response: Response) => void; let finishCurrent!: (response: Response) => void; diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 9495dc241..4587cf0dd 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BackendError, + askAgent, fetchMe, fetchOccupationRatingSources, fetchOccupationRatings, @@ -11,6 +12,7 @@ import { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe("backendFetch provider-error boundary", () => { @@ -118,3 +120,39 @@ describe("backendFetch provider-error boundary", () => { }); }); }); + + +describe("Ask polling cancellation", () => { + it.each(["before submission", "during fetch"])("preserves cancellation %s", async (moment) => { + const controller = new AbortController(); + const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }), + ); + vi.stubGlobal("fetch", fetchMock); + if (moment === "before submission") controller.abort(); + const result = askAgent("token", "Question", false, undefined, controller.signal).catch((error: unknown) => error); + if (moment === "during fetch") controller.abort(); + expect(await result).toBe(controller.signal.reason); + expect(fetchMock).toHaveBeenCalledTimes(moment === "before submission" ? 0 : 1); + }); + + it("stops during the poll delay without another request", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ ask_job_id: "job-1", job_status_code: "queued" }), { status: 202 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ job_status_code: "running" }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ job_status_code: "succeeded", answer: {} }))); + vi.stubGlobal("fetch", fetchMock); + const result = askAgent("token", "Question", false, undefined, controller.signal).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + controller.abort(); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await result).toBe(controller.signal.reason); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5db021a05..b56f502d7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -563,6 +563,7 @@ async function backendFetch( }, }); } catch { + init?.signal?.throwIfAborted(); throw new BackendError(path, 0); } if (!response.ok) { @@ -1369,16 +1370,15 @@ export function optionalKnowledgeCutoffIso(value: string): string | undefined { return parsed.toISOString(); } -/** Submit the question as an asynchronous job and poll it to completion. - * The signature and resolved value are unchanged from the old synchronous - * call, so callers (AskAgentPanel) keep their existing pending/complete - * states without modification. */ +/** Submit and poll an Ask job; aborting retires client I/O, not the server job. */ export async function askAgent( accessToken: string, question: string, verifyExternal = false, knowledgeCutoff?: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const requestBody: { question: string; verify_external: boolean; @@ -1386,15 +1386,19 @@ export async function askAgent( } = { question, verify_external: verifyExternal }; if (knowledgeCutoff) requestBody.knowledge_cutoff = knowledgeCutoff; const submitted = await backendFetch("/api/ask", accessToken, { + signal, method: "POST", body: JSON.stringify(requestBody), }); const deadline = Date.now() + ASK_POLL_CEILING_MS; for (;;) { + signal?.throwIfAborted(); const job = await backendFetch( `/api/ask/jobs/${submitted.ask_job_id}`, accessToken, + { signal }, ); + signal?.throwIfAborted(); if (job.job_status_code === "succeeded" && job.answer) { return job.answer; } @@ -1404,7 +1408,17 @@ export async function askAgent( if (Date.now() > deadline) { throw new Error("Ask Agent timed out waiting for an answer. Try again."); } - await new Promise((resolve) => setTimeout(resolve, ASK_POLL_INTERVAL_MS)); + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ASK_POLL_INTERVAL_MS); + signal?.addEventListener("abort", onAbort, { once: true }); + }); } } From 2242ef05cbd9cc61e836d87e1a838e7a8edd2589 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 13:43:30 +0900 Subject: [PATCH 3/6] fix(ui): keep transport errors out of operation guidance --- AGENTS.md | 4 ++++ docs/product-technical-gap-baseline.md | 29 ++++++++++++++++++++++++++ frontend/src/App.tsx | 2 +- frontend/src/AskAgentPanel.test.tsx | 13 ++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 024c31257..208448b45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,3 +437,7 @@ authenticated screen. Preserve the abort reason instead of reporting a network failure, and clear delay timers/listeners. Result-admission guards remain necessary for already-resolved work. Verify both transport cancellation and A → B → A state admission; neither establishes server-job cancellation. + +Keep transport exceptions at the diagnostic boundary. Buyer-facing error copy +must not use String(error) or provider detail; reuse existing localized recovery +guidance and preserve explicitly supported status-specific behavior. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cb169893b..d624bd581 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -999,3 +999,32 @@ pre-submission abort, in-flight fetch abort, and delay cancellation without anot request. Lint and TypeScript/production build passed with the existing chunk warning. This supersedes the earlier focused timeout result, not the outstanding full-suite, hosted, real-account, or page-latency acceptance requirements. + + +### Shared operation-error copy (2026-09-07) + +Ask's current-error leak came from the shared orchestratorUnavailableMessage +fallback used by Chat, Keymen extraction, Evaluation, Commitment derivation, +and Ask. Non-503 errors were stringified into buyer copy. Three negative cases +(network failure, HTTP 403, HTTP 500) reproduced missing recovery guidance and +visible transport details. The fallback now reuses the existing translated +view-recovery sentence; the existing 503 saved-evidence guidance is unchanged. +No second catalog or dependency was introduced. This covers this formatter's +five callers, not every error path in the application. + +The Ask/API run passed 17/18 cases, including all three new error-copy checks; +the existing cutoff/public-verification test exceeded its unchanged deadline. +A subsequent sibling-test invocation failed before test execution because the +Vitest forks worker did not respond. Neither is labeled a green suite. + +Separately, hosted Tests run 34082387676 on ontology PR #959 commit +`96ce3de6190f1f66f140663f034427fd4d78d3a4` passed the frontend's 544 tests across +58 files and its lint/build, plus 1768 backend tests (147 skipped, one warning). +Skipped tests and repository-job success do not prove independent review, +all required central checks, private runtime acceptance, or deployment. + +The correctly selected four existing 503 sibling tests passed in 6.86 s: +Chat, Evaluation, Keymen extraction, and Commitment derivation retained their +saved-evidence guidance. Lint and production build passed; the existing chunk +warning remains. The earlier worker-start failure ran no tests and is not +counted as a sibling assertion failure or success. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f57b202a..d4f6e1afd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -160,7 +160,7 @@ function orchestratorUnavailableMessage(err: unknown, action: string): string { if (err instanceof BackendError && err.status === 503) { return `${action} ${t("is temporarily unavailable.")} ${t("Saved evidence is still available.")}`; } - return String(err); + return t("This view is unavailable. Refresh once; if it fails again, contact your administrator."); } function LanguageSwitcher({ accessToken }: { accessToken?: string }) { diff --git a/frontend/src/AskAgentPanel.test.tsx b/frontend/src/AskAgentPanel.test.tsx index 87fcf61cb..9a9132709 100644 --- a/frontend/src/AskAgentPanel.test.tsx +++ b/frontend/src/AskAgentPanel.test.tsx @@ -8,6 +8,19 @@ describe("AskAgentPanel public verification", () => { vi.unstubAllGlobals(); }); + it.each([0, 403, 500])("shows recovery guidance instead of transport details for %s", async (status) => { + const fetchMock = status === 0 + ? vi.fn().mockRejectedValue(new Error("synthetic private transport detail")) + : vi.fn().mockResolvedValue(new Response(JSON.stringify({ detail: "synthetic private transport detail" }), { status })); + vi.stubGlobal("fetch", fetchMock); + const { container } = render(); + fireEvent.change(screen.getByLabelText("Ask a question"), { target: { value: "Question" } }); + fireEvent.click(screen.getByRole("button", { name: "Ask" })); + expect(await screen.findByText("This view is unavailable. Refresh once; if it fails again, contact your administrator.")).toBeInTheDocument(); + expect(container.textContent).not.toMatch(/BackendError|HTTP|\/api\/ask|synthetic private/); + expect(screen.getByRole("button", { name: "Ask" })).toBeEnabled(); + }); + it.each(["credential", "unmount"])("aborts transport on %s retirement", async (retirement) => { const fetchMock = vi.fn().mockImplementation(() => new Promise(() => {})); vi.stubGlobal("fetch", fetchMock); From e8487b6f25394aaa005d683547dc7d6253cb6a34 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 14:12:09 +0900 Subject: [PATCH 4/6] docs: record hosted Ask integration evidence --- AGENTS.md | 5 +++++ docs/product-technical-gap-baseline.md | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 208448b45..773eccf4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -386,6 +386,11 @@ exist on the post. ## CI gates +Record hosted results with their exact commit and run URL, including skipped +tests and warnings. Counts from independent PRs are not evidence that their +changes were integrated. Preserve local failures alongside later hosted success; +an evidence-only commit still needs its own current-head required checks. + `.github/workflows/tests.yml` runs the full suite on every PR to `main`. Do not weaken, skip, or `continue-on-error` a failing check -- fix the underlying cause or, for a genuine false positive in a third-party scanner, diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d624bd581..9becc00db 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1028,3 +1028,21 @@ Chat, Evaluation, Keymen extraction, and Commitment derivation retained their saved-evidence guidance. Lint and production build passed; the existing chunk warning remains. The earlier worker-start failure ran no tests and is not counted as a sibling assertion failure or success. + +### Ask hosted integration evidence (2026-09-07) + +[Tests run 34084171460](https://github.com/ContextualWisdomLab/LineageWeave/actions/runs/34084171460) +completed successfully on PR #972 code revision +`2242ef05cbd9cc61e836d87e1a838e7a8edd2589`. The frontend job passed 540 tests +across 58 files, lint, and production build. The backend job passed 1768 tests +with 147 skipped and one warning in 1251.00 s. This is the first full hosted +success recorded here for the combined Ask authorization, transport retirement, +and shared error-copy changes; the earlier local timeout evidence remains above. + +The 540-test result belongs to this Ask revision; the 544-test result above +belongs to the independent ontology revision. Neither test count proves that +the other PR's changes were integrated. Skipped integration cases, independent +review, central required checks, product-owned authentication, eight-language +database resources, deployment, and authenticated all-page p95 remain separate +acceptance obligations. Subsequent documentation commits require their own +current-head checks; this run remains evidence for the cited code revision. From 8e14f11295c0ffeb26a270ee9b7d7b21f5f2c895 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 7 Sep 2026 14:18:15 +0900 Subject: [PATCH 5/6] fix(ask): observe live jobs without a browser deadline --- AGENTS.md | 5 +++ .../0039-global-ask-agent-source-boundary.md | 39 +++++++++++++++++++ docs/product-technical-gap-baseline.md | 19 +++++++++ frontend/src/api.test.ts | 17 ++++++++ frontend/src/api.ts | 13 +------ 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 773eccf4e..81adf8ec9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,6 +443,11 @@ failure, and clear delay timers/listeners. Result-admission guards remain necess for already-resolved work. Verify both transport cancellation and A → B → A state admission; neither establishes server-job cancellation. +Do not infer Ask job failure from browser observation age. Queue wait and model +execution are distinct; follow durable terminal status or native cancellation. +Before removing a worker execution deadline, inspect orphan recovery: an +age-only requeue rule can duplicate a still-live computation without claim fencing. + Keep transport exceptions at the diagnostic boundary. Buyer-facing error copy must not use String(error) or provider detail; reuse existing localized recovery guidance and preserve explicitly supported status-specific behavior. diff --git a/docs/adr/0039-global-ask-agent-source-boundary.md b/docs/adr/0039-global-ask-agent-source-boundary.md index 889c0b34f..25dd710fa 100644 --- a/docs/adr/0039-global-ask-agent-source-boundary.md +++ b/docs/adr/0039-global-ask-agent-source-boundary.md @@ -38,3 +38,42 @@ explicit next action; the agent never fabricates an answer or citation. response. - The initial context is bounded to 50 recent rows. Retrieval/reranking is a later upgrade if corpus size or prompt budget requires it. + +## Proposed amendment: client observation lifetime (2026-09-07) + +This amendment remains Proposed pending protected review. It does not change +the Accepted evidence-source decision above. + +### Context and decision drivers + +The client currently abandons a durable Ask job fifteen minutes after +submission, including queue wait. A synthetic deferred-status regression +reproduces abandonment after a queued job becomes running, before its next +succeeded response can be read. Elapsed observation time does not establish +provider failure. The requested model policy has no default application limit. + +### Considered options and proposed outcome + +Retaining or increasing the fixed ceiling bounds browser polling but still +rejects valid work solely because time elapsed. A second browser timeout setting +duplicates policy outside contextual-orchestrator. Instead, continue the existing +two-second polling until a terminal response, transport failure, or native +AbortSignal cancellation. Keep credential-generation admission checks for work +that already resolved when the screen was retired. + +### Consequences and confirmation + +The browser can observe late answers without resubmitting paid work. Polling can +continue indefinitely while a visible screen follows a stranded job; cancellation +on unmount or credential change still retires client I/O, not the server job. +The regression must advance past the former ceiling, observe a nonterminal +status, and then receive the actual completed answer. Existing cancellation +tests must continue passing without timer or listener leaks. + +This is only the client observation decision. The backend's 600-second execution +deadline, answer socket limit, and 660-second age-based orphan recovery remain +an unresolved policy conflict. Removing execution limits requires a separate +worker-liveness and claim-fencing decision so recovery cannot duplicate a live +computation. Preserve ADR 0213's rule against holding pooled database connections +during provider work. No model administrator contract or end-to-end unlimited +execution is established by this amendment. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9becc00db..5d461c8a6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1046,3 +1046,22 @@ review, central required checks, product-owned authentication, eight-language database resources, deployment, and authenticated all-page p95 remain separate acceptance obligations. Subsequent documentation commits require their own current-head checks; this run remains evidence for the cited code revision. + +### Ask observation lifetime (2026-09-07; proposed ADR 0039 amendment) + +A synthetic API regression moved the browser clock sixteen minutes forward +after a queued response. The next status was running, with a succeeded answer +available afterward. The client threw its fixed fifteen-minute timeout instead +of fetching that answer: 1 failed, 10 passed before repair. Removing the elapsed +observation ceiling from the shared askAgent poller preserved terminal responses +and native cancellation. The correctly selected API and Ask panel suites passed +19/19 in 11.65 s. An initial selection named a nonexistent panel file and ran +only the API suite (11/11); it is not counted as panel coverage. + +The backend still enforces a 600-second execution deadline and a shorter answer +socket timeout. Recovery requeues running rows after 660 seconds because it +assumes the deadline ended any live computation. Removing that deadline alone +would invalidate the recovery assumption and could duplicate work. Worker +liveness, claim fencing, crash recovery, and the contextual-orchestrator model +administrator contract remain required before claiming the requested default-null +end-to-end model lifetime. No provider or private-runtime call was made here. diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 4587cf0dd..2f9f6f501 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -156,3 +156,20 @@ describe("Ask polling cancellation", () => { expect(vi.getTimerCount()).toBe(0); }); }); + +it("keeps reading a live queued job after fifteen minutes", async () => { + vi.useFakeTimers(); + const startedAt = Date.now(); + const answer = { answer: "Completed source-grounded answer", citations: [] }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ ask_job_id: "synthetic-job", job_status_code: "queued" }), { status: 202 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ job_status_code: "queued" }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ job_status_code: "running" }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ job_status_code: "succeeded", answer }))); + vi.stubGlobal("fetch", fetchMock); + const result = askAgent("synthetic-token", "Synthetic question").catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + vi.setSystemTime(startedAt + 16 * 60 * 1000); + await vi.advanceTimersByTimeAsync(4000); + expect(await result).toEqual(answer); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b56f502d7..ed8c19653 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1343,15 +1343,8 @@ export function askPostChat(accessToken: string, postId: string, question: strin }); } -/** How often the queued Ask job is polled, and for how long overall. - * A live orchestrator answer can take minutes under shared-gateway load, - * so the ceiling is generous; the poll interval keeps the reader's - * "Thinking..." state honest without hammering the backend. */ +/** Poll durable work until completion or client retirement; queue age is not failure. */ const ASK_POLL_INTERVAL_MS = 2000; -// Must exceed the backend's whole pipeline for one job — queue wait plus -// the 600 s job deadline — and the e2e suite's own answer deadline, so a -// stored answer is never abandoned by the client that asked for it. -const ASK_POLL_CEILING_MS = 15 * 60 * 1000; interface AskJobStatus { ask_job_id: string; @@ -1390,7 +1383,6 @@ export async function askAgent( method: "POST", body: JSON.stringify(requestBody), }); - const deadline = Date.now() + ASK_POLL_CEILING_MS; for (;;) { signal?.throwIfAborted(); const job = await backendFetch( @@ -1405,9 +1397,6 @@ export async function askAgent( if (job.job_status_code === "failed") { throw new Error(job.failure_detail || "Ask Agent could not answer this question."); } - if (Date.now() > deadline) { - throw new Error("Ask Agent timed out waiting for an answer. Try again."); - } await new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); From 40742bcf48858c22ee9726693669391836b9a5ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:33:30 +0900 Subject: [PATCH 6/6] fix(ask): preserve cancellation reason during body parsing --- frontend/src/api.test.ts | 25 +++++++++++++++++++++++++ frontend/src/api.ts | 8 +++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index 2f9f6f501..930ec8940 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -173,3 +173,28 @@ it("keeps reading a live queued job after fifteen minutes", async () => { await vi.advanceTimersByTimeAsync(4000); expect(await result).toEqual(answer); }); + + +it.each([200, 503])("preserves custom cancellation reason while parsing a %i response body", async (status) => { + const controller = new AbortController(); + const retirement = new Error("authorized screen retired"); + let rejectBody: (reason: unknown) => void = () => undefined; + const body = new Promise((_resolve, reject) => { + rejectBody = reject; + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockImplementation(() => body), + } as unknown as Response); + vi.stubGlobal("fetch", fetchMock); + + const result = askAgent("token", "Question", false, undefined, controller.signal).catch( + (error: unknown) => error, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + controller.abort(retirement); + rejectBody(new DOMException("The operation was aborted.", "AbortError")); + + expect(await result).toBe(retirement); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ed8c19653..896fc9ef3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -574,11 +574,17 @@ async function backendFetch( detail = body.detail; } } catch { + init?.signal?.throwIfAborted(); detail = undefined; } throw new BackendError(path, response.status, detail); } - return response.json() as Promise; + try { + return (await response.json()) as T; + } catch (error) { + init?.signal?.throwIfAborted(); + throw error; + } } export interface LineageGraphNode {