diff --git a/BUILD-LOG.md b/BUILD-LOG.md index d1c772602..47a881262 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5983,3 +5983,7 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr 2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 before | Prepared checkout t3/b15e72fa at 7e849a7b0b635d34950a5c5c2e4b78c4238fe651. Console answers currently require two-option grant requests. Scope: ordinary 2–9 choices through existing preview/confirmation/resolve, preserved grant and identity gates; no tracker/role/PRD/runtime changes. Baseline bus 12/12; initial WebUI 12 pass/5 fail from missing oauth4webapi@3.8.8, restored with locked npm ci --ignore-scripts, then WebUI 17/17 including Chromium. Intended RED: broker 14 pass/3 fail decision-not-eligible; HTTP/browser 12 pass/5 fail ordinary confirmation 503 or absent option panel; real SSO browser 0 pass/1 fail zero buttons instead of three. Owner continuation asks for user in changed prose, technical identifiers retained for #798. 2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 after | Ordinary user-routed decisions now expose authoritative kind/options through the existing two Console verbs; confirmation uses the selected key/text and ordinary receipts say Recorded. The digest binds the complete decision row and raise authorization/grant context; stale membership/context refuses transactionally. Grant approve/deny/summary and one-use executor path, OIDC/session/origin checks, role attribution and authority remain intact. Cancellation, navigation, reload, closure and changed-context refresh drop UI confirmations without replay. Added real fake-OP signed-in Chromium -> HTTP -> broker third-option coverage: one resolution, verified-subject audit, raiser notice and zero grant activity. RED-to-GREEN also checked project/spec digest binding. Targeted broker 17/17 and WebUI 25/25; final full bus 1407/1407 and WebUI 509/509, zero failures/skips. First full runs: bus 1406 pass/1 fail at unchanged host-run T27 sweep (expected one removed dir, got []), isolated T27 group 4/4 and full rerun green without changing it; WebUI 507 pass/1 fail/1 skip due absent package-local esbuild@0.28.2, restored with its lockfile, isolated build/provenance 7/7 and full rerun green. A newly added SSO test transport initially used nonexistent broker.release; corrected to disconnect after diagnosing its timeout, without weakening assertions. No independent reviewer launched; controller provides review. No tracker, role JSON, PRD, host/service/config/secrets changes, push, merge, PR or deployment. Internal identifier migration remains #798 / Gitea #1840. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 before | Fix base 7b51dbbba4778e7cb4d171dbf4c6e10f011b926e in the original prepared t3/b15e72fa checkout. Verify Important I1 in the existing Chromium harness: an obsolete A inbox/trail read (including kept-data fallback on error) must not replace shared answer state behind B controls. Scope is the decision state/render generation boundary only; controller supplies re-check. M1 editor-build warning remains deferred; no bus, lifecycle, rename, dependency, tracker or live operations. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 Console fix round 1 after | Verified Important I1 with three real Chromium regressions on 7b51dbbba: delayed A inbox, delayed A trail and failed delayed A trail with cached fallback all left B displayed but sent A confirmation URLs (0 pass/3 fail, no fixture/module errors). Root cause: decisionView mutated shared answer before render discarded obsolete HTML; fallback could start after a newer render too. Pass the originating render validity closure (generation, exact route, signed-in state) through both live and kept-data view paths and check before decision answer-state mutation; use the same check before committing page HTML. Small decision-state fix only; no renderer cleanup. GREEN I1 3/3; covering browser/SSO/HTTP 28/28; full WebUI 512/512, zero failures/skips/cancellations. Full output retains M1 ignored-bare-import warning at unchanged prosemirror-transform mark.ts import; deferred per controller. No bus file changed or bus suite rerun, no lifecycle/editor/rename/dependency/tracker/live changes, no subagent or reviewer, no push/merge/PR. Controller supplies fresh re-check. Prior evidence preserved; round 1 logs/report under /mnt/storage/scratch/tmp/mosaic-797-. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 5377e1358..ba0892b36 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1982,3 +1982,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-03 | Lead Codex GPT-6.1 Sol, resumed c5c1e8ba | Jason request-to-product intake, general Console decision buttons and autonomous product workflow (#797, Gitea #1839) | Recovered owner decisions and current code; queue next sage returned historical ledger row 7, direct owner request takes priority; drafted scoped design and acceptance, both Claude research/review delegates failed account rate limit; no product/host/config/role change, no provider substitution; owner alignment and coding-model exception outstanding. 2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/b15e72fa | #797 / Gitea #1839 stage 1 ordinary Console answers | 2–9 authoritative choices, selected-option confirmation/receipt, full-context digest and lifecycle coverage; targeted broker 17 and WebUI 25, full bus 1407 and WebUI 509 passing with zero skips; initial unchanged T27 flake and locked dependency restoration recorded in BUILD-LOG; local commit only, controller review pending, no trackers or external state changed. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent, original t3/b15e72fa checkout | #797 / Gitea #1839 Console fix round 1, reviewer I1 | Real-browser delayed inbox/trail and cached-error races RED 0/3 -> GREEN 3/3; originating generation/route guard protects answer-state mutation and HTML commit; covering 28/28 and full WebUI 512/512, zero skips; M1 warning retained, controller re-check pending; local commit only, no bus/lifecycle/editor/rename/tracker/live changes. diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 460e64e5d..f01227138 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -433,8 +433,10 @@

${page.total} messages · ${page.unread} unread

${body} `; } - async function decisionView(get, id) { + async function decisionView(get, id, current) { const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open); + // Obsolete reads, including kept-data fallback, must not replace the displayed answer state. + if (!current()) return ''; if (!d) return notFound(`No decision with id ${txt(id)}.`); const c = d.closed; const status = c ? (c.op === 'resolved' ? `Resolved: ${txt(c.by)} chose “${txt(c.choice)}”${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}.` : `${esc(c.op[0].toUpperCase() + c.op.slice(1))} by ${txt(c.by)} ${when(c.at)}${c.note ? `: ${txt(c.note)}` : ''}.`) : `Open since ${when(d.at)}.`; @@ -1187,12 +1189,12 @@ // ---------------------------------------------------------------- router // #/ and anything that is not a #/ route is the control board; the rest is this file's. const routeOf = () => location.hash.startsWith('#/') ? location.hash.slice(1) : '/'; - function view(get, path, q, opts) { + function view(get, path, q, opts, current) { const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } }); if (parts.includes(null)) return notFound(); const [a, b, c] = parts, n = parts.length; if (a === 'inbox' && n === 1) return inboxView(get); - if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`); + if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`); if (a === 'messages' && n === 1) return messagesView(get, q); if (a === 'tasks' && n === 1) return tasksView(get); if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`); @@ -1244,6 +1246,7 @@ // Leaving a decision discards its confirmation immediately, including a preview in flight. if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null; const g = ++gen; + const current = () => g === gen && route === routeOf() && !out(); document.body.classList.toggle('on-bus', !board); $('bus-view').hidden = board; sections(path); @@ -1281,15 +1284,15 @@ try { await live('messages'); } catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; } } - html = await view(live, path, new URLSearchParams(query), opts); readAt = live.oldest(); } + html = await view(live, path, new URLSearchParams(query), opts, current); readAt = live.oldest(); } catch (err) { if (err.code === 'signed-out') return; const kept = reader(false); - try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; } + try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts, current); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; } catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; } if (refusal(err)) forget(); } - if (g !== gen) return; + if (!current()) return; freshBus(state, SOURCES[path.split('/')[1]]); const k = !navigated && shown === route ? keep() : null; unmountEditor(); diff --git a/packages/webui/tests/decide-browser.test.mjs b/packages/webui/tests/decide-browser.test.mjs index 81a0e30b8..9ec2a99aa 100644 --- a/packages/webui/tests/decide-browser.test.mjs +++ b/packages/webui/tests/decide-browser.test.mjs @@ -36,6 +36,15 @@ const STUB = `(() => { const c = await (await real(url, init)).json(); return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) }); } + if (window.__delayRead?.url === u) { + const delay = window.__delayRead; + window.__delayRead = null; + const response = await real(url, init); + window.__readHeld = true; + await new Promise(resolve => { window.__releaseRead = resolve; }); + if (delay.reject) throw new TypeError('Delayed read failed'); + return response; + } if (u === '/api/bus/inbox' && window.__changedDecision) { const body = await (await real(url, init)).json(); body.rows = body.rows.map(row => row.id === window.__changedDecision ? { ...row, console: { ...row.console, digest: 'e'.repeat(64) } } : row); @@ -69,7 +78,7 @@ function hostileGrant(store, grant) { return id; } -async function setup({ ordinary = false } = {}) { +async function setup({ ordinary = false, paired = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-')); const store = new Store(root), b0 = new Broker({ store, businesses }); const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] }; @@ -83,6 +92,11 @@ async function setup({ ordinary = false } = {}) { options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: `Add to existing project ${HOSTILE}` }], recommendation: 'new/autonomous', approvalChoice: 'new/autonomous', } }).id : null; + const other = paired ? b0.request(chief, { verb: 'decision.raise', args: { + action: 'prd.approve', question: 'Decision B: choose a project', blocking: false, + options: [{ key: 'B-0', text: 'B autonomous' }, { key: 'B-1', text: 'B guided' }, { key: 'B-2', text: 'B existing project' }], + recommendation: 'B-0', approvalChoice: 'B-0', + } }).id : null; const reader = b0.bindReader({ business: 'demo' }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const f = await fixture(), b = await browser(); @@ -105,7 +119,7 @@ async function setup({ ordinary = false } = {}) { }; const settle = () => new Promise(r => setTimeout(r, 600)); const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; - return { b, store, base, open, deploy, bare, normal, wait, text, has, posts, queue, click, page, settle, done }; + return { b, store, base, open, deploy, bare, normal, other, wait, text, has, posts, queue, click, page, settle, done }; } const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } }); @@ -372,3 +386,41 @@ test('a changed authoritative context drops an open ordinary confirmation before assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0); } finally { await s.done(); } }); + +for (const [read, reject] of [['inbox', false], ['trail', false], ['trail', true]]) { + test(`I1: obsolete A ${read} read${reject ? ' failure with cached fallback' : ' completion'} cannot redirect B answer controls`, { timeout: 120000 }, async () => { + const s = await setup({ ordinary: true, paired: true }); + try { + // Warm A's cache so the failed read also exercises the kept-data continuation. + await s.page('sso&decide', s.normal); + await s.b.evaluate('document.querySelector("#pause").click(), true'); + assert.equal(await s.b.evaluate('document.querySelector("#pause").getAttribute("aria-pressed")'), 'true'); + const url = read === 'inbox' ? '/api/bus/inbox' : `/api/bus/trail?subject=${encodeURIComponent(s.normal)}`; + await s.b.evaluate(`window.__delayRead = ${JSON.stringify({ url, reject })}; document.querySelector('#refresh').click(); true`); + await s.wait('window.__readHeld === true'); + + await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.other}`); + await s.wait('document.querySelector("#bus-view .s1-q")?.textContent === "Decision B: choose a project" && document.querySelector("[data-decide=option-2]")?.textContent === "B existing project"'); + await s.b.evaluate('window.__releaseRead(), true'); + await s.settle(); + assert.equal(await s.text('#bus-view .s1-q'), 'Decision B: choose a project'); + assert.equal(await s.text('[data-decide=option-2]'), 'B existing project'); + + await s.queue({ status: 200, body: { id: s.other, kind: 'decision', action: 'answer', question: 'Decision B: choose a project', choice: 'B-2', text: 'B existing project', confirmation: 'b'.repeat(64) } }); + await s.click('option-2'); + const [preview] = await s.posts(); + assert.equal(preview.url, `/api/decisions/${encodeURIComponent(s.other)}/confirmation`, 'B controls must request confirmation for B after the obsolete A continuation'); + assert.deepEqual(JSON.parse(preview.body), { choice: 'B-2' }); + await s.wait('document.querySelector("[data-decide=confirm]")'); + assert.equal(await s.text('#bus-view .s1-q'), 'Decision B: choose a project'); + assert.match(await s.text('.s1-confirm'), /B-2.*B existing project/); + await s.queue({ status: 200, body: { id: s.other, op: 'resolved', choice: 'B-2' } }); + await s.click('confirm'); + await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); + const posts = await s.posts(); + assert.equal(posts.length, 2); + assert.equal(posts[1].url, `/api/decisions/${encodeURIComponent(s.other)}/resolve`); + assert.deepEqual(JSON.parse(posts[1].body), { choice: 'B-2', confirmation: 'b'.repeat(64) }); + } finally { await s.done(); } + }); +}