From 653a8f2b85631c1a3e28c5a090028bdfd921732c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:52:19 -0400 Subject: [PATCH 1/5] perf(planning): run the journey's applies from cached bytecode Each journey apply went through round.sh, which probes Python and then runs round.py as a script: Python never caches a script's bytecode, so every call recompiled the 3521-line file (about 40 ms here) on top of the bash and probe launches. japply and jhandle now run `python -m round` from the surface directory, which reuses round's cached bytecode; the command, its arguments and the data dir are unchanged, so every phase sees the same writes in the same batches. The other round.sh calls in the suite still cover the wrapper. The pause before the n3 Notes reply now waits for the next whole second instead of a full second. Notes carry whole-second times, so n3 still gets a later time than n2. Measured on this host (WSL2): - Replay of the 24 journey applies, server running, no browser: round.sh 2951/2780/2761 ms, python -m round 1628/1985/1662 ms. - Full suite before: apply 2507ms/24, handle 527ms/5, sleep 1002ms/1. - Full suite after (two runs): apply 2459ms/24 and 1891ms/24, handle 533ms/5 and 405ms/5, sleep 338ms/1 and 570ms/1. Refs: #8757 Co-Authored-By: Claude Opus 5.5 --- ...erf-8757-journey-apply-cached-module-4c1e9a7d.md | 7 +++++++ plugins/planning/surface/surface-checks.sh | 13 +++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 .changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md diff --git a/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md b/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md new file mode 100644 index 00000000000..96132251b6b --- /dev/null +++ b/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- surface-journey test: the shell's applies and handles between page phases run `round` as a module, which reuses its cached bytecode instead of recompiling `round.py` on each call, and the pause before the last Notes reply waits only for the next whole second. diff --git a/plugins/planning/surface/surface-checks.sh b/plugins/planning/surface/surface-checks.sh index 9c1b23f13c8..0211c4c5005 100755 --- a/plugins/planning/surface/surface-checks.sh +++ b/plugins/planning/surface/surface-checks.sh @@ -363,14 +363,16 @@ print(json.dumps({"live": len(live), "answered": sum(r[1] == "answered" for r in for n in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do sed "s/__PORT__/$jport/; s/__PHASE__/$n/" tests/ui_journey.js >"$tmp/uj$n.js" done + # The repeated writes run round as a module from this directory: Python then reuses its cached + # bytecode, where round.sh runs round.py as a script that recompiles all of it on every call. jhandle() { local s read -r -a s <<<"$(unhandled "$j")" - [[ "${#s[@]}" -eq 0 ]] || jtime handle bash "$here/round.sh" --dir "$j" handle --seq "${s[@]}" >/dev/null + [[ "${#s[@]}" -eq 0 ]] || jtime handle "$py" -m round --dir "$j" handle --seq "${s[@]}" >/dev/null } japply() { # name ops-json printf '%s' "$2" >"$j/ops/$1.json" - jtime apply bash "$here/round.sh" --dir "$j" apply --file "$j/ops/$1.json" >/dev/null || bad "journey: ops $1 refused" + jtime apply "$py" -m round --dir "$j" apply --file "$j/ops/$1.json" >/dev/null || bad "journey: ops $1 refused" } jrun() { run_code "$tmp/uj$1.js" "$tmp/uj$1.out"; } jrun 1 @@ -460,8 +462,11 @@ print(json.dumps({"live": len(live), "answered": sum(r[1] == "answered" for r in "constraints": "Builds stop at ten minutes and share one cache."}}]}' # A Notes reply after the restatement: the restate notice outranks it in phase 16. japply n2 '{"ops": [{"op": "note-reply", "needsAnswer": true, "text": "Anything else before the Brief?"}]}' - # An ordinary Claude note after it must not unpin the question. - jtime sleep sleep 1 + # An ordinary Claude note after it must not unpin the question. Notes carry whole-second times, + # so waiting for the next second to start gives it a later time than n2, as a full second would. + local us=${EPOCHREALTIME#*.} wait=1 + [[ "$us" =~ ^[0-9]{6}$ && "$us" != 000000 ]] && wait=$(printf '0.%06d' "$((1000000 - 10#$us))") + jtime sleep sleep "$wait" japply n3 '{"ops": [{"op": "note-reply", "text": "The restatement is posted."}]}' jrun 16 # Phase 17 confirms the third restatement. The shell then posts a status and the finish op, From b4255e1901eb40bded061c023e5f8db33c5096fb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:53:10 -0400 Subject: [PATCH 2/5] chore(planning): mark the journey apply fragment bump none The change only touches the surface test, so it releases nothing, as the earlier #8757 test fragment did. Refs: #8757 Co-Authored-By: Claude Opus 5.5 --- .../planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md b/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md index 96132251b6b..96cc88cc74c 100644 --- a/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md +++ b/.changes/planning/perf-8757-journey-apply-cached-module-4c1e9a7d.md @@ -1,5 +1,5 @@ --- -bump: patch +bump: none --- ### Changed From a830b70c96891c6cf3d22301bb47c12e8f97ee2c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:02:55 -0400 Subject: [PATCH 3/5] perf(planning): drop the journey's two poll and timeout waits, fix its toast race Timing every page call per phase showed settle already returns as soon as the page has caught up (1 of about 160 calls ran to its cap, while the server was stopped). Two waits ran long on the happy path: - Phase 13 pressed l, which closes the Activity panel phase 10 left open, then clicked #flyClose with a 2 s timeout that expired every run. It now clicks #flyClose only when the press opened the panel; the panel ends closed either way, as before. - Phase 2's third second-tab read waited for the next 3 s /api/state poll. A visibilitychange on the visible tab now reads /api/state at once (catchUp in index.html). The second read still comes from the poll, so the polling path stays covered. Phase 13's "queued, Claude is busy" check could race: the note toast is set after the send, and the next state frame re-renders the Notes panel (focus is on Send note, not the textarea), emptying the toast. A MutationObserver now records every text the toast shows, and the check passes only when one of them says Queued, Claude is busy. Per-phase seconds summed over phases 1-20, this host: - before: 67, 71, 66 (phase 2: 10, 11, 10; phase 13: 5, 9, 5) - after: 58, 61, 62 (phase 2: 7, 7, 7; phase 13: 3, 3, 3) All three after runs: PASS=254 FAIL=0 SKIP=0. Refs: #8757 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/tests/ui_journey.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index e616592f8be..727af2dca16 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -190,6 +190,8 @@ async page => { // the user journey in order on one page, no reload after phase const h2 = await lineIs(status.text) ? await head() : {}; ok("a status set after the newest unhandled event is not dimmed", h2.line === "Checked the cache key" && h2.old === false && h2.work === "Claude is working on Q99, Q1", JSON.stringify(h2)); status = null; + // h2 came from the 3 s poll; a visible tab's visibilitychange reads /api/state at once, so h3 need not wait for the next poll + await tab.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); await tab.waitForFunction(() => document.getElementById("claudeLine").hidden || document.getElementById("clText").textContent !== "Checked the cache key", null, {timeout: 8000}).catch(() => {}); const h3 = await head(); ok("clearing the status leaves the working line unchanged", h3.line !== "Checked the cache key" && h3.work === "Claude is working on Q99, Q1", JSON.stringify(h3)); @@ -713,10 +715,13 @@ async page => { // the user journey in order on one page, no reload after phase await page.request.get((await page.evaluate(() => location.origin)) + "/api/wait?after=0&timeout=1", {headers: {"X-Interview-Token": await token()}}); // delivers it: a wake is in flight await until(() => /^Claude is working on /.test(document.getElementById("workLine").textContent), 5000); // the page has seen the delivery, the state queued() reads ok("the first note posted", sent.ok(), sent.status()); - await page.click("#title"); await page.keyboard.press("l"); await page.click("#flyClose", {timeout: 2000}).catch(() => {}); + // l toggles Activity, left open since phase 10: close it only when this press opened it + await page.click("#title"); await page.keyboard.press("l"); if (await page.$eval("#fly", el => el.classList.contains("open"))) await page.click("#flyClose"); await page.evaluate(() => document.querySelector('[data-panel="notes"]').click()); await settle(300); + // The next state frame re-renders the Notes panel, emptying the toast, so record every text it shows. + await page.evaluate(() => { const seen = window.__noteToasts = []; new MutationObserver(() => { const t = (document.getElementById("noteToast") || {}).textContent; if (t && seen[seen.length - 1] !== t) seen.push(t); }).observe(document.body, {subtree: true, childList: true, characterData: true}); }); await page.fill("#noteText", "second note"); await page.click("[data-sendnote]"); - ok("a save while a wake is in flight says queued, Claude is busy", await until(() => /Queued, Claude is busy/.test((document.getElementById("noteToast") || {}).textContent || ""), 4000), await text("#noteToast")); + ok("a save while a wake is in flight says queued, Claude is busy", await until(() => window.__noteToasts.some(t => /Queued, Claude is busy/.test(t)), 4000), JSON.stringify(await page.evaluate(() => window.__noteToasts))); await page.click("#flyClose", {timeout: 2000}).catch(() => {}); } if (PHASE === 17) { // after the wrap-up the user confirms the third restatement; the shell has written nothing since phase 16 From 9612dd7cb0584dc9ab89d92199811c68a143e2f0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:05:14 -0400 Subject: [PATCH 4/5] test(planning): disconnect the journey's note-toast observer after its check The phase-13 toast recorder observed the whole page body and was never disconnected, so it ran on every DOM change through phase 20 of the same page. It now disconnects right after the queued-toast check. Verified: surface-journey.test.sh PASS=254 FAIL=0. Refs: #8757 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/tests/ui_journey.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/planning/surface/tests/ui_journey.js b/plugins/planning/surface/tests/ui_journey.js index 727af2dca16..07b7ff06b78 100644 --- a/plugins/planning/surface/tests/ui_journey.js +++ b/plugins/planning/surface/tests/ui_journey.js @@ -719,9 +719,10 @@ async page => { // the user journey in order on one page, no reload after phase await page.click("#title"); await page.keyboard.press("l"); if (await page.$eval("#fly", el => el.classList.contains("open"))) await page.click("#flyClose"); await page.evaluate(() => document.querySelector('[data-panel="notes"]').click()); await settle(300); // The next state frame re-renders the Notes panel, emptying the toast, so record every text it shows. - await page.evaluate(() => { const seen = window.__noteToasts = []; new MutationObserver(() => { const t = (document.getElementById("noteToast") || {}).textContent; if (t && seen[seen.length - 1] !== t) seen.push(t); }).observe(document.body, {subtree: true, childList: true, characterData: true}); }); + await page.evaluate(() => { const seen = window.__noteToasts = []; const mo = window.__noteToastObserver = new MutationObserver(() => { const t = (document.getElementById("noteToast") || {}).textContent; if (t && seen[seen.length - 1] !== t) seen.push(t); }); mo.observe(document.body, {subtree: true, childList: true, characterData: true}); }); await page.fill("#noteText", "second note"); await page.click("[data-sendnote]"); ok("a save while a wake is in flight says queued, Claude is busy", await until(() => window.__noteToasts.some(t => /Queued, Claude is busy/.test(t)), 4000), JSON.stringify(await page.evaluate(() => window.__noteToasts))); + await page.evaluate(() => window.__noteToastObserver.disconnect()); // the page lives on through phase 20 await page.click("#flyClose", {timeout: 2000}).catch(() => {}); } if (PHASE === 17) { // after the wrap-up the user confirms the third restatement; the shell has written nothing since phase 16 From c3dcae13a4de09668a5911f7706250130dad36f4 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:19:22 -0400 Subject: [PATCH 5/5] fix(planning): keep the journey's one-second fallback on bash without EPOCHREALTIME The next-second wait before note n3 read ${EPOCHREALTIME#*.} under set -u, so on bash 4, which has no EPOCHREALTIME, the suite stopped with an unbound variable before phase 16. It now reads ${EPOCHREALTIME:-} first and waits the full second when the variable is missing, as jtime already does. Refs: #8757 Co-Authored-By: Claude Opus 5.5 --- plugins/planning/surface/surface-checks.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/planning/surface/surface-checks.sh b/plugins/planning/surface/surface-checks.sh index 0211c4c5005..69381b88b9f 100755 --- a/plugins/planning/surface/surface-checks.sh +++ b/plugins/planning/surface/surface-checks.sh @@ -464,7 +464,8 @@ print(json.dumps({"live": len(live), "answered": sum(r[1] == "answered" for r in japply n2 '{"ops": [{"op": "note-reply", "needsAnswer": true, "text": "Anything else before the Brief?"}]}' # An ordinary Claude note after it must not unpin the question. Notes carry whole-second times, # so waiting for the next second to start gives it a later time than n2, as a full second would. - local us=${EPOCHREALTIME#*.} wait=1 + local now=${EPOCHREALTIME:-} us wait=1 # bash 4 has no EPOCHREALTIME: keep the full second + us=${now#*.} [[ "$us" =~ ^[0-9]{6}$ && "$us" != 000000 ]] && wait=$(printf '0.%06d' "$((1000000 - 10#$us))") jtime sleep sleep "$wait" japply n3 '{"ops": [{"op": "note-reply", "text": "The restatement is posted."}]}'