From 4da5006632294cfe6d311868e92110b59f72cd8a Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:59:19 -0300 Subject: [PATCH 001/115] docs(plan): triage, spec and draft plan for open issues #348 and #350 to #358 Draft plan for nine issues (9 phases, 47 tasks) with its first Codex review, and the #350 design in its own folder with the review that returned it for rework. Co-Authored-By: Claude Fable 5.2 --- .../evidence.md | 167 +++++++ .../execute-plan.md | 100 ++++ .../phases/PHASE-01-terminal-panel-fixes.md | 301 ++++++++++++ .../PHASE-02-git-and-pull-request-lookups.md | 208 ++++++++ ...SE-03-worktree-records-and-removal-test.md | 132 +++++ .../phases/PHASE-04-update-install-guard.md | 121 +++++ ...E-05-reasoning-row-scroll-and-dashboard.md | 93 ++++ ...SE-06-popups-picker-and-markdown-images.md | 101 ++++ .../PHASE-07-server-odds-and-e2e-harness.md | 114 +++++ .../phases/PHASE-08-labs-and-probes.md | 95 ++++ .../PHASE-09-docs-gates-and-pull-request.md | 92 ++++ .../2026-11-13-issue-batch-348-358/plan.md | 188 ++++++++ .../progress.md | 61 +++ .../reviews/plan-review-1.md | 103 ++++ .../sources/issue-348.md | 133 +++++ .../sources/issue-350.md | 114 +++++ .../sources/issue-351.md | 9 + .../sources/issue-352.md | 7 + .../sources/issue-353.md | 14 + .../sources/issue-354.md | 11 + .../sources/issue-355.md | 11 + .../sources/issue-356.md | 11 + .../sources/issue-357.md | 11 + .../sources/issue-358.md | 14 + .../2026-11-13-issue-batch-348-358/spec.md | 258 ++++++++++ .../terminal-screen-design.md | 453 ++++++++++++++++++ .../2026-11-13-issue-batch-348-358/triage.md | 24 + .../reviews/design-review-1.md | 60 +++ 28 files changed, 3006 insertions(+) create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/evidence.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/plan.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/progress.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-348.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-350.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-351.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-352.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-353.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-354.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-355.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-356.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-357.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/sources/issue-358.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/spec.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/terminal-screen-design.md create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/triage.md create mode 100644 docs/plans/2026-11-13-terminal-screen-350/reviews/design-review-1.md diff --git a/docs/plans/2026-11-13-issue-batch-348-358/evidence.md b/docs/plans/2026-11-13-issue-batch-348-358/evidence.md new file mode 100644 index 000000000..9eaf90e5b --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/evidence.md @@ -0,0 +1,167 @@ +# Evidence: open issues #348 and #350 to #358 + +Status: Draft +Review: script check passed; a fresh read by a same-vendor subagent, round 1, its findings applied +Source: sources/issue-348.md, issue-350.md to issue-358.md (10 GitHub issues of this repository, exported 2026-11-13 with every comment) + +One item is one line of an issue that reports something: a statement by the reporter, a listed follow-up, or a comment. In #348 and #350 the rest of the text (traces, hypotheses, proposed requirements, spike results, review findings) was read in full; it is analysis written by agents, not a report of a problem, and is not counted. + +## Sources +- sources/issue-348.md: bug report, 2 items (the two reporter statements) +- sources/issue-350.md: feature issue, 3 items (the problem, what stays affected, 1 comment) +- sources/issue-351.md: bug report, 3 items +- sources/issue-352.md: test report, 2 items +- sources/issue-353.md: follow-up list, 9 items +- sources/issue-354.md: follow-up list, 6 items +- sources/issue-355.md: follow-up list, 6 items +- sources/issue-356.md: follow-up list, 6 items +- sources/issue-357.md: follow-up list, 6 items +- sources/issue-358.md: follow-up list, 6 items (5 lines and 1 comment) +- Skipped: none + +## Problems + +### 1. A person looking at a terminal sees broken or stale output +- Who has it: people who run AI or full-screen programs in a terminal; 2 of the 8 items name two windows on one terminal +- Count: 8 items +- Items: sources/issue-348.md :: line 5; sources/issue-348.md :: line 14; sources/issue-350.md :: line 5; sources/issue-350.md :: line 7; sources/issue-350.md :: line 114; sources/issue-353.md :: line 7; sources/issue-353.md :: line 12; sources/issue-358.md :: line 7 +- Distinct people: 1 reporter account +- Strength: weak, one kind of source and 1 reporter; 2 items carry measured reproductions (5 of 5 runs each); the symptom of #348 itself was never captured +> "intermittent rendering trouble is corrected by a right-panel resize; it occurs with WebGL on and off" @ sources/issue-348.md :: line 14 +> "Once a program's last full paint has left that history, the rebuilt screen lacks cells" @ sources/issue-350.md :: line 5 +> "the returning window showed 4 stale rows that an attached window did not (5 of 5 runs)" @ sources/issue-350.md :: line 114 +- What they asked for: +> "The server keeps an emulated terminal screen per session and a snapshot becomes a checkpoint" @ sources/issue-350.md :: line 11 +- Contradicting voices: none found + +### 2. A person watching pull-request status can get a late, repeated or stale answer +- Who has it: people who watch pull requests from Source Control +- Count: 5 items +- Items: sources/issue-355.md :: line 5; sources/issue-355.md :: line 6; sources/issue-355.md :: line 7; sources/issue-355.md :: line 8; sources/issue-355.md :: line 9 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter; no item was reproduced by a run, and 1 item is marked not decided +> "Failed lookups are never stored or backed off" @ sources/issue-355.md :: line 6 +- What they asked for: +> "Track the task or join it at quiesce, and prove it with an injected fault" @ sources/issue-355.md :: line 5 +- Contradicting voices: none found + +### 3. A person using the desktop app gets wrong or missing feedback in a panel +- Who has it: people using the Host dashboard, the terminal size notice, popups over a browser preview, the model picker and Files +- Count: 5 items +- Items: sources/issue-353.md :: line 5; sources/issue-353.md :: line 8; sources/issue-358.md :: line 5; sources/issue-358.md :: line 8; sources/issue-358.md :: line 13 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter; 1 item is marked not measured +> "can draw under a native browser preview" @ sources/issue-358.md :: line 5 +> "still requests a relative image against the app's own address, so the image does not load" @ sources/issue-358.md :: line 13 +- What they asked for: +> "Add a lease that carries a rectangle so the preview hides only when the popup overlaps it" @ sources/issue-358.md :: line 5 +- Contradicting voices: none found + +### 4. A person reading worktree warnings can get a missing, lost or misplaced creation record +- Who has it: people who create and remove worktrees and read the profiler's worktree audit +- Count: 5 items +- Items: sources/issue-356.md :: line 5; sources/issue-356.md :: line 6; sources/issue-356.md :: line 7; sources/issue-356.md :: line 8; sources/issue-356.md :: line 9 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter, found by reading the code +> "A worktree recreated at that path in the gap loses its record" @ sources/issue-356.md :: line 5 +- What they asked for: +> "Delete by the id read when the worktree was resolved" @ sources/issue-356.md :: line 5 +- Contradicting voices: none found + +### 5. A person with unsaved Files edits during a remote update install can lose the guard or be left with a dialog that never closes +- Who has it: people whose desktop app installs an update asked for from another device +- Count: 5 items +- Items: sources/issue-357.md :: line 5; sources/issue-357.md :: line 6; sources/issue-357.md :: line 7; sources/issue-357.md :: line 8; sources/issue-357.md :: line 9 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter, found by reading the code; 1 item is marked not decided +> "A renderer reload during an allowed install forgets the block" @ sources/issue-357.md :: line 5 +> "stays up with no way out" @ sources/issue-357.md :: line 6 +- What they asked for: +> "Add one, such as asking the desktop for the install state again" @ sources/issue-357.md :: line 6 +- Contradicting voices: none found + +### 6. A maintainer cannot trust a red result from a test or lab run +- Who has it: agents and maintainers who validate a change on Windows and Linux +- Count: 3 items +- Items: sources/issue-352.md :: line 3; sources/issue-352.md :: line 5; sources/issue-353.md :: line 6 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter; 1 item carries a measured rate (about one of three whole-suite runs). Line 7 of issue-358.md, counted under problem 1, also names a test that was flaky +> "It is a timing bound under load, not a behaviour change" @ sources/issue-352.md :: line 5 +- What they asked for: +> "Make the test wait on the condition with a generous deadline, or control time" @ sources/issue-352.md :: line 5 +- Contradicting voices: none found + +### 7. A person reading a chat on Windows loses their place when expanding a reasoning row +- Who has it: people using the Windows desktop app +- Count: 3 items +- Items: sources/issue-351.md :: line 3; sources/issue-351.md :: line 5; sources/issue-351.md :: line 7 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter, with one measured run in a Windows 11 arm64 VM +> "the expanded header moved 1014 px against a 2 px limit" @ sources/issue-351.md :: line 3 +- What they asked for: nothing specific +- Contradicting voices: none found + +### 8. A person whose Git command was stopped by force can be left with a lock file +- Who has it: people who stop a pull or a commit with hooks from BiBCode +- Count: 2 items +- Items: sources/issue-354.md :: line 5; sources/issue-354.md :: line 6 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter, found by reading the code +> "A forced stop while `git pull` is merging leaves its `index.lock` behind" @ sources/issue-354.md :: line 5 +- What they asked for: nothing specific +- Contradicting voices: none found + +### 9. A person or a server on a different version can meet behavior that was never run or is tied to the wrong capability +- Who has it: people who connect a newer client to an older server +- Count: 2 items +- Items: sources/issue-353.md :: line 10; sources/issue-358.md :: line 6 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter +> "A server that advertises one without the other would break this silently" @ sources/issue-358.md :: line 6 +- What they asked for: +> "Add a dedicated capability" @ sources/issue-358.md :: line 6 +- Contradicting voices: none found + +### 10. A person using the Linux desktop app can meet stalls and growing memory +- Who has it: people using the Linux packaged app +- Count: 2 items +- Items: sources/issue-353.md :: line 9; sources/issue-353.md :: line 11 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter; measured under software rendering only +> "main-thread stalls up to 2.7 s on the settings to thread route change" @ sources/issue-353.md :: line 11 +- What they asked for: nothing specific +- Contradicting voices: none found + +### 11. A maintainer misses a documented quiesce step or test filter +- Who has it: agents and maintainers who follow the profiler reference and the validation runbook +- Count: 2 items +- Items: sources/issue-354.md :: line 8; sources/issue-354.md :: line 9 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter +> "does not list the `git-writes` step among the `server.stop` quiesce steps" @ sources/issue-354.md :: line 8 +- What they asked for: nothing specific +- Contradicting voices: none found + +### 12. A person can be shown Git sign-in guidance for text that is not Git's refusal +- Who has it: people whose commit message or hook output contains Git's phrases +- Count: 1 items +- Items: sources/issue-354.md :: line 7 +- Distinct people: 1 reporter account +- Strength: weak, 1 reporter, found by reading the code +> "A commit message or a hook's output that contains both phrases triggers the sign-in guidance" @ sources/issue-354.md :: line 7 +- What they asked for: +> "Anchor it to Git's own `fatal:` line" @ sources/issue-354.md :: line 7 +- Contradicting voices: none found + +## Not grouped +- Count: 6 items +- Items: sources/issue-353.md :: line 3; sources/issue-354.md :: line 3; sources/issue-355.md :: line 3; sources/issue-356.md :: line 3; sources/issue-357.md :: line 3; sources/issue-358.md :: line 3 +- About: the opening line of each follow-up list, which says where its items came from and whether they were reproduced. + +## What this evidence cannot show +- Every item comes from one reporter account. The 8 follow-up issues (#351 to #358) say they were written while validating one pull request. No other user's report is in the sample, so nothing here shows how many people meet any of these problems. +- The 20 follow-up items of #354 to #357 were found by reading the code and were not reproduced by a run. They show that the code permits the problem, not that anyone met it. +- Three counted items are marked by their source as not decided or not measured (issue-355.md line 9, issue-357.md line 9, issue-358.md line 8). They show an open question, not an observed problem. +- The exact visual symptom of #348 is not in the sources: no screenshot or recording was provided. +- Whether the real Claude CLI shows the stale rows of #350's comment is not known; the measurement used a stand-in program. +- Looked for and not found: a report of any of these items from a second person, in the issues' comments and in the duplicate searches recorded in triage.md. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md b/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md new file mode 100644 index 000000000..8fd4ce601 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md @@ -0,0 +1,100 @@ +# Execute plan: Issue batch #348 and #351 to #358 + +You are about to execute an approved implementation plan. Read this whole file before doing anything. + +- Plan folder: docs/plans/2026-11-13-issue-batch-348-358 +- Spec: docs/plans/2026-11-13-issue-batch-348-358/spec.md (binding authority; the plan argues from it) +- Planned on: Claude Code, 2026-11-13 +- Plan reviewed by: Codex gpt-6.1-sol, effort high +- Base branch: main +- Recommended mode: Workflow, because round 1 has seven phases that own disjoint files and the plan has 47 tasks + +## 1. Check the plan + +1. Read `progress.md`, `plan.md`, `spec.md` and every file in `phases/`. + Check that `mstack-receive-review` is in this session's skill list (as `mstack-receive-review` or `:mstack-receive-review`), because every review of this run is processed with it. When it is missing, stop before any work and print the install commands: `claude plugin install mstack-receive-review@mstack` (Claude Code) or `codex plugin add mstack-receive-review@mstack` (Codex), after ` plugin marketplace add mubeda/mstack`, or `npx skills add mubeda/mstack --skill mstack-receive-review -g -a -y`; name `mstack-setup`, and say a new session is needed after the install. +2. If `progress.md` says `Plan status: Draft`, stop. Tell the user the plan is not approved and ask them to approve it, or to finish it with `mstack-plan`. +3. Compute the sha256 of `spec.md` (`sha256sum spec.md` or `shasum -a 256 spec.md`). If its first 12 hex digits differ from the `Spec` line of `progress.md`, the spec changed after planning. Stop and ask the user whether to re-plan or continue on the old plan. +4. When `progress.md` has a `Branch` line, continue on that branch. Tasks marked Closed are never redone, but check first that each one's last logged commit is on the branch (`git merge-base --is-ancestor HEAD`). When it is not, because a workflow closed the task and stopped before integrating it, cherry-pick the logged commits from the logged branch. When that branch is gone, set the task back to In progress and log why. +5. A task marked In progress was interrupted. Read its log lines and `git log` on the branch, then finish it from where the evidence stops. + +## 2. Ask the user first + +Before any other work, ask the user these questions in one message, using the host's question tool (in Claude Code `AskUserQuestion`; in Codex a numbered list in chat, then wait). Skip a question only when the user's opening message already answers it. + +1. How should the plan be executed? + (a) Inline. This session implements every task in order, red-green, then gets one cross-vendor review of the whole branch. + (b) Workflow. In Claude Code, the `mstack-codex-review-workflow` skill (Codex reviews). In Codex, the `mstack-claude-review-workflow` skill (Claude reviews). Parallel writer lanes, a cross-vendor review per lane, one integrated branch. + (c) Codex develops. In Claude Code only, the `mstack-claude-pm-codex-dev` skill: Codex writes each phase, and Claude reviews, validates and commits it. + I recommend (b), because round 1 has seven phases that own disjoint files and the plan has 47 tasks. +2. Which reviewer model and effort? I recommend `gpt-6.1-sol` and `high` for Codex reviewers (the plan review used them), `opus` and `medium` for Claude reviewers. +3. May this run commit each finished task to the branch in `progress.md`'s `Branch` line, or, when there is none, use the existing branch `claude/open-issues-pr-dc3c33` (created from `main` for this batch) and commit to it? Pushing and opening a pull request happen only when the user asks. + +## Before round 1 (plan decision P3) + +Run the packaged `chat-reasoning-rows` spec on the base commit in the Linux lab at a 1000x720 viewport, with the Profiler on, and log the result in `progress.md`. Task 5.1 reads it. Follow the user's standing rules for this repository: subagents run on `sonnet` or `haiku`, or `opus` at effort medium for complex work, never above; check free disk (at least 60 GB) before each round of Rust writers and keep at most four at once; every review is triaged with the advisor; push once, after the gates pass. + +## 3. Workflow execution + +1. Load the workflow skill for this host. If it is not installed, give the user its install command and offer inline execution instead. + - Claude Code: `claude plugin marketplace add mubeda/mstack`, then `claude plugin install mstack-codex-review-workflow@mstack`, then start a new session. + - Codex: `codex plugin marketplace add mubeda/mstack`, then `codex plugin add mstack-claude-review-workflow@mstack`, then start a new session. + - Either host, with the skills CLI: ask the user the scope (global or this repository), the method (symlink or copy) and the agents, then run `npx skills add mubeda/mstack --skill -a -y`, adding `-g` for global and `--copy` for copy. + - Choice (c), Claude Code only: `claude plugin marketplace add mubeda/mstack`, then `claude plugin install mstack-claude-pm-codex-dev@mstack`, then start a new session; or with the skills CLI, after the same three questions, `npx skills add mubeda/mstack --skill mstack-claude-pm-codex-dev -a claude-code -y`. It also needs the OpenAI Codex plugin for Claude Code; `mstack-setup` reports and installs it. +2. Give the workflow this plan folder as its work items, and tell it that every review is processed with `mstack-receive-review` without asking the user. It reads the folder as an `mstack-plan` plan: every task is a work item, `progress.md` is its status file, and each task's Skills, Read first and Done when go into the lane brief. +3. The answers to questions 2 and 3 above answer the workflow's own model, effort and commit questions. The workflow still asks its execution mode question, and Workflow mode is recommended when a round has more than one phase. For choice (c), the same two answers answer its reviewer and commit questions; it still asks which developer model and effort to use. + +## 4. Inline execution + +### Rules for the whole run + +1. Work on the branch from question 3. Never work directly on the default branch without the user's consent. +2. Set `Executor: inline` and `Branch: ` in `progress.md`. +3. Keep going without status check-ins. Stop and ask only for: an irreversible or destructive operation, a security-sensitive action, a side effect outside this repository (a push, a publish, a merge), or a plan so broken that every way forward is a guess. When the context or the session is running out, stop at a safe point, make sure `progress.md` is current, write a handoff with `mstack-handoff` when it is installed, and give its path; the next session continues from `progress.md` and the handoff. +4. When the plan is wrong in a small way (a renamed function, a moved file), make the smallest change that still satisfies the spec, and log it in `progress.md` as `Ruling: , because . Cost if wrong: .` +5. Never claim a task is done without fresh evidence: run its Done when command in this session, read the whole output and the exit code, and copy the evidence into the log. +6. This session's model does every task, so a task's Tier line does not change it. The tier limits what you delegate: a read-only helper (a search, a log read) runs on the cheapest model this host offers, set explicitly, and no helper runs on a model above the task's tier. In Claude Code that is `Agent` with the model and effort of `haiku`, effort low for a cheap helper and of `sonnet`, effort medium for a standard one. In Codex, pass them as `-m` and `model_reasoning_effort` on the helper run: the cheapest Codex model the host offers, effort low for a cheap helper, `gpt-6.1-sol`, effort medium for a standard one. A helper started without them runs on this session's model. + +### Per task + +Pick the next task by dependencies. A task is ready when every task in its Depends on line is Closed and every phase its phase depends on is Closed. Among ready tasks, take the lowest phase, then the lowest ID. Skip Closed tasks and tasks marked removed or dropped. A task that depends on a removed or dropped task stays Open, and the final report lists it as blocked. + +1. Set the task to In progress in `progress.md` and add a log line. +2. Load every skill the phase and the task list (in Claude Code with the Skill tool; in Codex by reading the skill's SKILL.md). The plan names skills without a plugin prefix, so use whichever form this host lists, `:` or ``. For a skill this host does not have, follow the task's steps without it and note that in the log. +3. Read every file and source in the phase's and the task's Read first. +4. Follow the steps in order. After each command, compare the output with its `Expected:` line. + - Red-green tasks: see the test fail for the expected reason before writing the code, then see it pass. Do the "prove the test can fail" step, and always restore the code afterwards. + - The output does not match because the code is wrong: debug it before changing anything (load `mstack-diagnose` when it is installed). Never patch the symptom. + - The output does not match because the plan is wrong: apply rule 4. +5. Run the task's Done when command and the phase's lint and format commands. +6. Commit the task's files together with `progress.md`, where the task is now Closed and the log line carries the command, the result and the commit range. + +After a phase's last task, run its Checkpoint and log the result. A failing checkpoint reopens the task that owns the failure. + +### After the last task + +1. Run every check in `plan.md` › End-to-end verification and log each result. +2. Get one cross-vendor review of the whole branch, read-only, with the model and effort from question 2. Write its output outside the repository: `` below is the session's scratch folder when the host has one, otherwise `${TMPDIR:-/tmp}/mstack-plan-issue-batch-348-358`, created first. Put a path in double quotes in these commands when it contains a space. + - Claude Code (Codex reviews, through the `codex` skill of the skill-codex plugin; timeout at least 600 seconds for `high`, 1200 for `xhigh`, 1800 for `max`): + + Write this prompt to `/review-prompt.md` with your file tool, never with `echo` or a heredoc, so no shell expands anything in it: + + ```text + Read-only code review. Do not edit or run anything that changes files. Review branch against (git diff ...HEAD). The plan is in docs/plans/2026-11-13-issue-batch-348-358 and the spec it implements is docs/plans/2026-11-13-issue-batch-348-358/spec.md. Check that every requirement in the spec is met, and check correctness, regressions, missing tests and scope. Report each finding with file, line, the failing scenario and how to reproduce it. No findings is a valid result. + ``` + + ```bash + codex exec -m --config model_reasoning_effort="" --sandbox read-only -C - \ + < /review-prompt.md 2> /codex-review.err > /codex-review.md + ``` + - Codex (Claude reviews, through the `claude-review` skill of the agent-bridges `claude` plugin; it needs network and writes under the home folder, so run it as an approved command): + + ```bash + node "/scripts/claude-companion.mjs" review --wait \ + --cwd --base --scope branch \ + --model --effort /claude-review.md 2> /claude-review.err + ``` + This review reads the diff, not the spec, so check every requirement against the spec yourself. + - An empty output or a non-zero exit is never a clean review. Read the end of the `.err` file, fix the cause (a rejected model: use the other model offered in question 2), and rerun once. If it still fails, or this host's reviewer is not installed, warn the user that the review will be same-vendor, and run the same review prompt through a fresh, read-only subagent of this host (in Claude Code the `Agent` tool with the model and effort of `opus`, effort medium; in Codex a separate `codex exec -m --config model_reasoning_effort="" --sandbox read-only -C - < /review-prompt.md`, where those two are the model and effort of `gpt-6-astra`, effort medium, with the prompt above written to that file, as an approved command because it needs network). Log it as a same-vendor review, and say so in the final report. Never wait on the user for it. +3. Process the review with the `mstack-receive-review` skill, without asking the user anything. Verify every finding against the code and reproduce it before accepting it. Fix each real finding with a test you saw fail and then pass. Send an unclear finding back to the reviewer once. Reject a finding that is wrong, cannot be verified, or contradicts a decision the user already made, with the reason. Log every finding and its outcome in `progress.md`. +4. Report to the user: the goal and the outcome in the first line, then the final `progress.md` table, the end-to-end results, every Ruling, every review finding with what happened to it (fixed, rejected and why, deferred), and what was not run and why. Offer to open a pull request. Do not push without the user's request. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md new file mode 100644 index 000000000..974996cac --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md @@ -0,0 +1,301 @@ +# Phase 1: Terminal panel fixes + +- Goal: The terminal panel re-measures and repaints after a late font load, a late renderer creation and a same-size return. It keeps a bounded, content-free record of size decisions for development. It says when Fit is refused, uses a dedicated hand-over capability, retries WebGL in a new terminal and counts columns by Unicode 11. R16 and R43 are decided by tests that stay as proof. Every change has its interaction test green. +- Round: 1 +- Depends on: none +- Owns: `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`, `apps/web/src/components/terminalSizing.ts`, `apps/web/src/components/terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts`, `apps/web/src/components/terminalSizeDiagnostics.ts` (new), `apps/web/src/components/terminalSizeDiagnostics.test.ts` (new), `apps/web/src/components/terminalUnicode.ts` (new), `apps/web/src/components/terminalUnicode.xterm.test.ts` (new), `apps/web/package.json`, `pnpm-lock.yaml` (updated by the executor's install only), `packages/contracts/src/environment.ts`, `packages/contracts/src/environment.test.ts`, `packages/contracts/fixtures/**` (regenerated by `check:contracts` only), `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts` +- Must not touch: `apps/web/src/components/terminalReplyGuard.ts`, `packages/client-runtime/**`, `apps/server/src/terminal/**` (the #350 phases own them), `apps/web/src/components/ChatView.tsx`, and every doc under `docs/` (Phase 9 owns doc edits) +- Skills: `tdd` (each behavior change is red first), `vercel-react-best-practices` (required review of every change to the panel's effects and hooks), `mstack-diagnose` (R16 and R43 are decided by tests) +- Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data); `UI.md` (the Fit notice wording and its status region); `docs/architecture/overview.md`, the terminal size ownership paragraphs around "Resize requests publish `resized { size }`" (owner, mirror and hand-over rules this phase must keep); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (R1 to R5, R16, R17, R41 to R43, D4, D12, D13) +- Checkpoint: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx src/components/terminalSizeDiagnostics.test.ts src/components/terminalUnicode.xterm.test.ts src/components/terminalSizePolicy.test.ts src/components/terminalSizing.test.ts src/lib/terminalFont.test.ts` exits 0. Then `vp test run packages/contracts/src/environment.test.ts`, `vp run check:contracts`, and `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exit 0. Last, `vp check`, `vp run typecheck`, `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0. + +Every task edits `ThreadTerminalPanel.tsx` or its interaction test, so tasks run one after another in the listed order. The separate #350 plan edits the same file after this phase. Rust commands use the pinned toolchain (1.98.1) first on PATH, with `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`. Web test commands below are run from `apps/web`. + +Profiler coverage line for the final report: none applies. This phase adds no child process, no RPC method, no worktree removal, no binary mode, no shutdown step and no profiler event. The R4 record is a development-only in-memory ring, not a profiler record. R42 changes one existing `console.warn` in the browser, and that warning carries no terminal content. + +## Tasks + +### Task 1.1: Repaint after a late font load + +- Satisfies: R1. Given a rendered terminal with an unchanged mount size, when `document.fonts` dispatches `loadingdone`, then the terminal is re-measured and `clearTextureAtlas` and `refresh` each run once. An owner refits. A mirror keeps the server's columns and rows and sends no size claim. The owner, mirror and legacy assertions equal those of the existing device-pixel-ratio test. Given the terminal is unmounted, nothing runs. +- Depends on: none +- Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `scheduleResize`, the callback the pixel-ratio change already uses. It never calls `fit` directly: it runs `rendererRef.current?.size("layout")` in a frame and then the 50 ms `clearTextureAtlas` and `refresh`. `size("layout")` is `handleSizeTrigger("layout")`: + - Without size ownership (legacy) it calls `fitTerminalSafely` and sends the fitted size. + - With ownership it only re-measures through `updateFittedDimensions` (the fit addon's `proposeDimensions`, which does not resize xterm). An owner then claims, and xterm is resized from the ordered `resized` event. A mirror only updates its scale through `updateSizeNotice`. + + Produces a `loadingdone` listener on `document.fonts` that calls `scheduleResize`, removed in the same effect's cleanup. +- Skills: tdd (red-green for the new listener); vercel-react-best-practices (effect cleanup and dependency review) +- Read first: `ThreadTerminalPanel.tsx` (the effect that creates `new ResizeObserver(scheduleResize)`); the test "refits when the device pixel ratio changes under an unchanged mount" (its `vi.stubGlobal("matchMedia", …)` pattern) +- Tier: standard +- TDD: red-green +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"` exits 0 with every case passing. + +Steps + +1. [ ] Next to "refits when the device pixel ratio changes under an unchanged mount", write `it.each(["legacy", "owner", "mirror"] as const)("re-measures and repaints when a font finishes loading under an unchanged mount (%s)")` in `ThreadTerminalPanel.interactions.test.tsx`. Copy that test's setup: + - a fit mock that really resizes the fake grid (`terminal.cols` and `terminal.rows` take `xtermState.proposedDimensions`), as the real fit addon does; + - the snapshot sized 80 × 24 with `sizeClaim` `null` for owner and `"other-window"` for mirror; + - the owner's acknowledging `resized`. + + Stub `document.fonts` with an `EventTarget` that also has `status: "loaded"`. Set `xtermState.proposedDimensions = { cols: 80, rows: 23 }`, dispatch `new Event("loadingdone")`, flush the frame and advance 50 ms. Assert exactly what the pixel-ratio test asserts: + - legacy: `fit` called once, `terminal.rows` is 23, and a resize to 80 × 23 with no `sizeClaim` was sent; + - owner: a resize to 80 × 23 with its `sizeClaim` was sent, and after the `resized` event `terminal.rows` is 23; + - mirror: `fit` not called, `resizeSpy` not called, and `terminal.rows` still 24. + + In every mode, `clearTextureAtlas` ran once and `refresh` was called with `(0, terminal.rows - 1)`. Add a second test, "does nothing when a font finishes loading after unmount": unmount, dispatch, and assert that `fit`, `clearTextureAtlas`, `refresh` and `resizeSpy` were not called. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"`. Expected: FAIL in all three modes, `clearTextureAtlas` called 0 times. The mirror case must not fail on `fit`; if it does, the test setup differs from the pixel-ratio test. +3. [ ] In the mount-observer effect, add `document.fonts?.addEventListener("loadingdone", scheduleResize)` and the matching `removeEventListener` in its cleanup. Guard with `typeof document.fonts?.addEventListener === "function"`. Do not call `fitAddon.fit()` or `fitTerminalSafely` from the listener: a mirror's grid belongs to the server's size. +4. [ ] Run the same command. Expected: PASS. +5. [ ] Prove the test can fail: comment out the `addEventListener` line, then run the command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.2: Repaint a renderer created after host configuration + +- Satisfies: R2. Given an AI terminal waiting for host configuration, when configuration arrives and the pending font load then resolves, then `clearTextureAtlas` and `refresh` run on the new renderer and exactly one resize is issued for the initial geometry. A font callback for a disposed renderer does nothing. +- Depends on: 1.1 +- Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `ensureBundledTerminalFontLoaded(): Promise` from `apps/web/src/lib/terminalFont.ts` (unchanged), `rendererEpoch`, `rendererEpochRef` and `resizeRendererGenerationRef`. Produces a font effect whose dependency list is `[shouldRender, terminalFontFamily, rendererEpoch]`, with the stale-epoch guard `rendererEpoch !== rendererEpochRef.current` (the guard the WebGL effect already uses). +- Skills: tdd (red-green for the new dependency); vercel-react-best-practices (effect dependency and stale-closure review) +- Read first: `ThreadTerminalPanel.tsx` (the effect that calls `ensureBundledTerminalFontLoaded()`, and the WebGL effect's epoch guard); the test "waits for authoritative host config before attaching Codex"; the test "issues one resize for the initial geometry and skips unchanged fits from either path" +- Tier: standard +- TDD: red-green +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "host config"` exits 0, and so does the same command with `-t "issues one resize for the initial geometry"`. + +Steps + +1. [ ] Write `it("repaints a renderer created after host config once the bundled font loads")` next to "waits for authoritative host config before attaching Codex". Set `testState.serverConfig = null`. Make `testState.fontLoad` return a deferred promise. Mount a Codex viewport, then deliver the config. Resolve the font promise and flush the frame and timers. Assert `clearTextureAtlas` once and `refresh(0, rows - 1)` on the new renderer, and `resizeSpy` called exactly once. Add `it("ignores a font load that resolves after its renderer was disposed")`: hide the viewport before resolving, then assert neither call ran on the disposed terminal. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "host config"`. Expected: FAIL, `clearTextureAtlas` called 0 times. +3. [ ] Add `rendererEpoch` to the font effect's dependency list. Return early when `rendererEpoch !== rendererEpochRef.current`. Keep the existing generation and identity guards in the `.then` callback. +4. [ ] Run the step 2 command, then the same command with `-t "issues one resize for the initial geometry"`. Expected: both PASS. +5. [ ] Prove the test can fail: remove `rendererEpoch` from the dependency list, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.3: Assert the repaint on a same-size return + +- Satisfies: R3. When a mount returns from no area to the same positive size, the terminal repaints. Test only. +- Depends on: 1.2 +- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes the existing `it.each([false, true])("never sizes the PTY from a mount with no area and refits when it returns (ownership %s)")`. Produces nothing other tasks use. +- Skills: tdd (proving an added assertion can fail) +- Read first: that test, and the 50 ms trailing refresh in `scheduleResize` +- Tier: cheap +- TDD: exempt (the behavior exists; the task adds an assertion and proves it by a temporary break) +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "never sizes the PTY from a mount with no area"` exits 0 for both cases. + +Steps + +1. [ ] In that test, call `terminal.refresh.mockClear()` before the mount returns to its old width. After `resizeContainer()` and `flushFrame()`, advance the 50 ms timer and assert `expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1)`, while `resizeSpy` stays uncalled. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "never sizes the PTY from a mount with no area"`. Expected: PASS. +3. [ ] Prove the test can fail: comment out `terminal.refresh(0, terminal.rows - 1)` in the `refreshTimer` callback of `scheduleResize`, then run the command. Expected: FAIL on the new assertion. Restore it and run again. Expected: PASS. +4. [ ] Run `vp check src/components/ThreadTerminalPanel.interactions.test.tsx`. Expected: no errors. + +### Task 1.4: Development record of size and repaint decisions + +- Satisfies: R4. Each record holds the fields the spec lists. The ring keeps the last 200 records. With diagnostics off nothing is recorded. No record contains any byte written to the terminal. +- Depends on: 1.3 +- Files: create `apps/web/src/components/terminalSizeDiagnostics.ts`; test `apps/web/src/components/terminalSizeDiagnostics.test.ts`; modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `TerminalSizeTrigger` and `TerminalDimensions` from `terminalSizePolicy.ts`, and `TerminalResizeOutcome` from `@bibcode/contracts`. Produces these exact exports of `terminalSizeDiagnostics.ts`, which Task 1.8 and the separate #350 plan call: + - `export const TERMINAL_SIZE_DIAGNOSTICS_CAPACITY = 200;` + - `export type TerminalSizeDiagnosticTrigger = TerminalSizeTrigger | "font" | "pixel-ratio" | "resize-answer" | "repaint";` + - `export interface TerminalSizeDiagnosticRecord { readonly at: number; readonly trigger: TerminalSizeDiagnosticTrigger; readonly rendererGeneration: number; readonly mountWidth: number; readonly mountHeight: number; readonly fitted: TerminalDimensions | null; readonly applied: TerminalDimensions | null; readonly role: "owner" | "mirror" | "legacy"; readonly devicePixelRatio: number; readonly fontStatus: "loading" | "loaded" | "unavailable"; readonly renderer: "webgl" | "standard"; readonly request: { readonly cols: number; readonly rows: number; readonly outcome: TerminalResizeOutcome | "pending" | "failed" } | null; readonly repaint: "none" | "scheduled" | "done"; }` + - `export function setTerminalSizeDiagnosticsEnabled(enabled: boolean): void` (ignored unless `import.meta.env.DEV`; when it disables, it also clears the ring) + - `export function terminalSizeDiagnosticsEnabled(): boolean` + - `export function recordTerminalSizeDiagnostic(record: Omit): void` (a no-op when disabled; stamps `at` with `performance.now()`) + - `export function readTerminalSizeDiagnostics(): readonly TerminalSizeDiagnosticRecord[]` (oldest first, a copy) + - `export function clearTerminalSizeDiagnostics(): void` + + Records hold no thread, terminal or session ids and no strings from the terminal. In development, the panel module also sets `window.__bibcodeTerminalSizeDiagnostics = { enable: setTerminalSizeDiagnosticsEnabled, read: readTerminalSizeDiagnostics }`, which a developer calls from the console. +- Skills: tdd (ring and panel recording are red first); vercel-react-best-practices (recording must not add renders or effect dependencies) +- Read first: `ThreadTerminalPanel.tsx` (`handleSizeTrigger`, `completeClaim`, `finishClaim`, `requestTerminalResize`, `scheduleResize`, the font effect); `AGENTS.md` section Sensitive Data +- Tier: standard +- TDD: red-green +- Done when: `vp test run --project unit src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.interactions.test.tsx -t "size diagnostics"` exits 0. + +Steps + +1. [ ] In `terminalSizeDiagnostics.test.ts` write three tests: + - `it("keeps the last 200 records, oldest first")`: record 205 entries and assert the length is 200 and the first `rendererGeneration` is 5. + - `it("records nothing while disabled")`. + - `it("clears the ring when disabled")`. +2. [ ] In `ThreadTerminalPanel.interactions.test.tsx` write `it("records size diagnostics without terminal content")`. Enable diagnostics. Write the output `"secret-marker-value"` through the runtime, resize the container and flush the frame and the 50 ms timer. Assert that a record with trigger `"layout"` has every field set and `repaint` reaches `"done"`. Assert that `JSON.stringify(readTerminalSizeDiagnostics())` does not contain `"secret-marker-value"`. Write `it("records no size diagnostics while disabled")`. +3. [ ] Run `vp test run --project unit src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.interactions.test.tsx -t "size diagnostics"`. Expected: FAIL, the module is not found. +4. [ ] Create the module with the exports above, as a fixed-size array with a write index. In the panel, call `recordTerminalSizeDiagnostic` in five places: + - at the end of `handleSizeTrigger`, with its trigger, fitted, applied and the role from `sizeOwnership` and the mirror state; + - in `finishClaim` and in the `requestTerminalResize` completion, with trigger `"resize-answer"` and the outcome; + - when `scheduleResize` schedules the trailing refresh (`"scheduled"`) and when it runs (`"done"`); + - in the font effect; + - in the pixel-ratio handler. + + Read `mount.clientWidth`, `mount.clientHeight`, `globalThis.devicePixelRatio`, `document.fonts?.status ?? "unavailable"`, and the renderer kind from `webglLifecycleRef.current !== null`. Wrap each call site in `if (terminalSizeDiagnosticsEnabled())` so nothing is measured when diagnostics are off. +5. [ ] Run the step 3 command. Expected: PASS. +6. [ ] Prove the test can fail: make `recordTerminalSizeDiagnostic` ignore the enabled flag, then run the command. Expected: FAIL in "records nothing while disabled". Restore it and run again. Expected: PASS. +7. [ ] Run `vp check src/components/terminalSizeDiagnostics.ts src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.5: Say that the server kept the size when Fit sends nothing + +- Satisfies: R17 (D13). When "Fit to this window" is pressed and no size request can be sent, the size notice says that the server kept this size, in a status region. No request is sent and focus stays on the button. +- Depends on: 1.4 +- Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `renderer.fit()` (it calls `handleSizeTrigger("fit")` and compares `claimInFlight` before and after) and the size notice `div` with `role="status"`. Produces a `fitKept` boolean state in `TerminalViewport`. It is set to `true` by `fit()` when no new claim was sent. It is reset to `false` by `applySize` when the applied size or owner changes, and when a claim is sent. +- Skills: tdd (red-green for the new text); vercel-react-best-practices (one new state, set only from event paths); better-writing (UI.md review of the new sentence) +- Read first: `UI.md` (disabled and refused actions explain themselves); the tests "keeps focus on Fit to this window when no claim is sent" and "leaves the terminal focused after Fit to this window takes the size" +- Tier: standard +- TDD: red-green +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "Fit to this window"` exits 0, including the unchanged test "keeps focus on Fit to this window when no claim is sent". + +Steps + +1. [ ] Write `it("says the server kept this size when Fit to this window sends nothing")`, built like "keeps focus on Fit to this window when no claim is sent". After the second click, assert the notice's `role="status"` element contains the text `The server kept this size.`, the resize call count is unchanged and `document.activeElement` is the Fit button. Add `it("drops the kept-size sentence when the applied size changes")`: a later `resized` event with another size, then assert the sentence is gone. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "server kept this size"`. Expected: FAIL, the text is not found. +3. [ ] Add `const [fitKept, setFitKept] = useState(false)`. In `fit()`, call `setFitKept(claimInFlight === pending)`. In `applySize`, call `setFitKept(false)` when `!terminalSizesEqual(appliedSize, size)`. In the notice, render `{fitKept ? " The server kept this size." : ""}` inside the existing `` of the `role="status"` div, so no second live region is added. +4. [ ] Run the Done-when command. Expected: PASS, including "keeps focus on Fit to this window when no claim is sent". +5. [ ] Prove the test can fail: render the sentence only when `fitKept && false`, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Review the sentence against `UI.md`: it says what happened, in the user's words, and the Fit button stays available. Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.6: A dedicated hand-over capability + +- Satisfies: R41 (D12). The client applies the typed-last size hand-over rule only when the server advertises `terminalSizeHandOver`. A server without it gets the old behavior. +- Depends on: 1.5 +- Files: modify `packages/contracts/src/environment.ts`, `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts`, `apps/web/src/components/ThreadTerminalPanel.tsx`; test `packages/contracts/src/environment.test.ts`, the capability tests in `lifecycle.rs` and `production/control.rs`, and `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; regenerate `packages/contracts/fixtures/**` +- Interfaces: consumes `ExecutionEnvironmentCapabilities` and the panel's `userInputClaim` (`sizeOwnership && capabilities.terminalUserInputClaim === true`), which keeps driving `inputOrigin`. Produces the field `terminalSizeHandOver: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false)))` and a panel constant `sizeHandOver = sizeOwnership && serverConfig?.environment.capabilities?.terminalSizeHandOver === true`. That constant replaces `userInputClaim` only in `unownedUntilUsed = … && signal.size.sizeClaim === null`, and it is added to the renderer effect's dependency list. +- Skills: tdd (red-green for decoding, advertising and the panel rule); vercel-react-best-practices (the dependency list change) +- Read first: `packages/contracts/src/environment.ts` (`terminalUserInputClaim`); `environment.test.ts` "defaults terminal size ownership off and preserves advertised support"; `lifecycle.rs` `connect_descriptor_advertises_remote_update_support`; `production/control.rs` `environment_descriptor` and `environment_descriptor_advertises_remote_update_control_and_support`; the interaction tests "waits for use of a size a handing-over server released, but claims a restart's (#348)" and "keeps an older server's rule: every window claims a released size and answers meanwhile" +- Tier: standard +- TDD: red-green +- Done when: `vp test run packages/contracts/src/environment.test.ts` exits 0; `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exits 0 with 2 tests run; from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "released"` exits 0; `vp run check:contracts` exits 0 + +Steps + +1. [ ] Write the failing tests: + - In `environment.test.ts`, `it("defaults the terminal size hand-over off and preserves advertised support")`. + - In both Rust tests, add `assert_eq!(descriptor["capabilities"]["terminalSizeHandOver"], true)`. + - In the panel tests, set `terminalSizeHandOver: true` in "waits for use of a size a handing-over server released…". + - Add `it("keeps the older rule for a server with input claims but no hand-over capability")`. It uses `{ terminalSizeOwnership: true, terminalUserInputClaim: true }` and asserts a released size (`sizeClaim: null`) is claimed at once, as in "keeps an older server's rule…". +2. [ ] Run the four Done-when commands except `check:contracts`. Expected: each FAILS on the new assertion. The Rust one fails with `left: Null, right: true`. +3. [ ] Make the changes: + - Add the field to `environment.ts`. + - Add `"terminalSizeHandOver": true` to the `capabilities` literal in `lifecycle.rs`. + - Add `capabilities["terminalSizeHandOver"] = json!(true);` next to `terminalUserInputClaim` in `environment_descriptor`. + - Add `terminalSizeHandOver: false` to `makeTestExecutionEnvironmentCapabilities`. + - Add `terminalSizeHandOver?: boolean` to the interaction test's capabilities type. + - Add `sizeHandOver` to the panel as described. +4. [ ] Rerun the step 2 commands. Expected: PASS. +5. [ ] Prove the test can fail: use `userInputClaim` again in the `unownedUntilUsed` line, then run the panel command. Expected: FAIL in the new "older rule" test. Restore it and run again. Expected: PASS. +6. [ ] Run `vp run check:contracts`. Expected: FAIL at `git diff --exit-code --stat -- packages/contracts/fixtures`, listing regenerated fixtures. Stage those fixture files (`git add packages/contracts/fixtures`), check that the diff only adds `terminalSizeHandOver`, and run it again. Expected: PASS. +7. [ ] Run `cargo fmt --all --check`, `cargo clippy -p bibcode-server --all-targets -- -D warnings`, `vp check` on the changed TypeScript files, and `vp run typecheck`. Expected: no errors. + +### Task 1.7: Retry WebGL in a new terminal and say why it failed + +- Satisfies: R42. A new terminal in the same viewport tries WebGL again after an earlier context loss, and the fallback warning includes the cause. +- Depends on: 1.6 +- Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `webglFailedRef`, `webglDiagnosticRecordedRef`, `rendererEpochRef` (incremented in the terminal creation effect) and `recordWebglDiagnosticOnce`. Produces `recordWebglDiagnosticOnce(cause: unknown): void`, which calls `console.warn("[terminal] WebGL renderer unavailable; using the standard renderer.", { cause })`. Context loss passes `cause: "context lost"`; a load or attach error passes the caught error. Both refs are set back to `false` where `rendererEpochRef.current += 1` runs. +- Skills: tdd (red-green for the retry and the cause); vercel-react-best-practices (callback signature change) +- Read first: the WebGL effect (`addon.onContextLoss`, the `catch` block); the tests "falls back once on context loss and does not retry in the same viewport lifetime", "does not steal focus when WebGL resolves, toggles, or loses context" and "allows a fresh viewport component to retry after a prior component failed" +- Tier: standard +- TDD: red-green +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "WebGL"` exits 0, and so does the same command with `-t "context loss"`. + +Steps + +1. [ ] Write `it("tries WebGL again in a new terminal after a context loss in the same viewport")`. Trigger the context loss, then `setVisible(false)` and `setVisible(true)`, which creates a new xterm in the same viewport. Settle WebGL and assert `webglState.instances` has length 2. Write `it("includes the cause in the WebGL fallback warning")`, which rejects the addon import with `new Error("no context")` and asserts `console.warn` was called with the message and `{ cause: expect.objectContaining({ message: "no context" }) }`. Update "falls back once on context loss and does not retry in the same viewport lifetime" to expect the message with `{ cause: "context lost" }`. That test toggles the setting without a new terminal, so it still expects one instance. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "WebGL"`. Expected: FAIL, 1 instance instead of 2, and the warning has no cause. +3. [ ] Implement `recordWebglDiagnosticOnce(cause)`, pass the cause from both failure paths, and reset `webglFailedRef.current` and `webglDiagnosticRecordedRef.current` to `false` next to `rendererEpochRef.current += 1`. +4. [ ] Run both Done-when commands. Expected: PASS. +5. [ ] Prove the test can fail: remove the `webglFailedRef.current = false` reset, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.8: Decide whether a re-attach sends a transient size (R16) + +- Satisfies: R16. Using R4's record, a test replays an attach followed by a font-metrics change and an Activity dock change. + - If a size request with different rows is sent and then reverted, the settle rule is fixed so that only the final size is requested. + - Otherwise the result is recorded and no code changes. +- Depends on: 1.1, 1.4, 1.7 +- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; modify `apps/web/src/components/ThreadTerminalPanel.tsx` only in the defect branch +- Interfaces: consumes `readTerminalSizeDiagnostics()`, `setTerminalSizeDiagnosticsEnabled(true)`, `TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS` (100), `TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS` (500) and the mount-settle state in the renderer (`mountSettleRemaining`, `mountSizeChangedAt`, `mountUnsettledSince`). Produces, only in the defect branch, a renderer method `noteGeometryInputChanged(): void`. It sets `mountSizeChangedAt = performance.now()` and `mountUnsettledSince ??= mountSizeChangedAt`, and is called from the Task 1.1 `loadingdone` listener before `scheduleResize`. +- Skills: mstack-diagnose (decide by test; the test is the deliverable); tdd (red-green in the defect branch) +- Read first: `ThreadTerminalPanel.tsx` (`mountSettleRemaining`, `handleSizeTrigger`, the deferred claim timer); the test "mounts a correlated provider dock in the exact terminal viewport" (how the dock is mounted); "sends one claim at the final size after a mount passes through a few pixels" +- Tier: most capable +- TDD: red-green +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"` exits 0, and the outcome (defect fixed, or no defect) is written in the phase report with the recorded request sequence. + +Steps + +1. [ ] Write `it("sends only the final size after a re-attach, a font-metrics change and an Activity dock change (re-attach transient)")` with ownership on and the window focused. Steps of the test: + - Enable diagnostics and attach with a snapshot sized `{ cols: 85, rows: 30 }`. + - Within 100 ms, change the proposed rows to 33 and dispatch `loadingdone` (font metrics). + - Within another 100 ms, open the Activity dock, so the mount shrinks and the proposal becomes 30 rows. + - Flush all timers. + - From `readTerminalSizeDiagnostics()`, collect `request` rows in order. Assert there is no `request.rows` sequence that goes to a value and then back, and that the last request equals the final fit. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. + - Expected, defect branch: FAIL, with requests such as `[33, 30]`. Go to step 3. + - Expected, no-defect branch: PASS. Make the test fail on purpose by setting `TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS` to 0, confirm it fails, and restore it. Then log "R16: no transient; requests …" in the phase report and stop. +3. [ ] Defect branch: add `noteGeometryInputChanged()` to the renderer as in Interfaces. Call it from the `loadingdone` listener before `scheduleResize`, so a font change restarts the 100 ms quiet period like a mount change does. The dock change already resizes the mount and is already covered. +4. [ ] Run the step 2 command. Expected: PASS. Run `-t "sends one claim at the final size"` and `-t "does not claim the sizing minimum at the maximum wait"`. Expected: PASS. +5. [ ] Prove the test can fail: remove the call from the listener, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.9: Decide whether the grid is taller than its mount before the first snapshot (R43) + +- Satisfies: R43. A test records rows and mount height between mount and the first snapshot after a reload. + - If rows exceed the fitted rows, the terminal is fitted before it is shown. + - Otherwise the result is recorded. +- Depends on: 1.8 +- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; modify `apps/web/src/components/ThreadTerminalPanel.tsx` only in the defect branch +- Interfaces: consumes `terminalHostHasArea(host: Element): boolean` and `proposeTerminalDimensions(fit, host?): TerminalDimensions | null` from `terminalSizing.ts`, `fitTerminalSafely(fitAddon: FitAddon): boolean`, and `xtermState.proposedDimensions` in the test. Produces, only in the defect branch, a renderer flag `fittedBeforeFirstReset: boolean`. While it is `false`, the first `handleSizeTrigger("layout")` on a mount with area calls `fitTerminalSafely(fitAddon)` before any claim logic, in both ownership modes, and then sets the flag to `true`. The first `reset` render also sets it to `true`. +- Skills: mstack-diagnose (decide by test; the test is the deliverable); tdd (red-green in the defect branch) +- Read first: the test "sends keystrokes through the input scheduler before the first snapshot" (the `publishRuntime: false` mount); the test "attaches without dimensions when the mount is created with no area"; the creation effect lines after `terminal.open(mount)` +- Tier: most capable +- TDD: red-green +- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "before the first snapshot"` exits 0, and the outcome is written in the phase report with the recorded rows and mount height. + +Steps + +1. [ ] Next to "sends keystrokes through the input scheduler before the first snapshot", write `it.each([false, true])("keeps the grid within its mount before the first snapshot after a reload (ownership %s)")`. Steps of the test: + - Mount with `publishRuntime: false`, a mount height that fits 20 rows (`xtermState.proposedDimensions = { cols: 80, rows: 20 }`), and xterm's default 24 rows. + - Flush the 30 ms fit timer and the first frame. + - Read `terminal.rows`, `mount.clientHeight` and `proposeTerminalDimensions` before any snapshot is ingested. + - Assert `terminal.rows <= 20`. +2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "before the first snapshot"`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. + - Expected, defect branch: FAIL with `24 > 20` in one or both ownership cases. Go to step 3. + - Expected, no-defect branch: PASS. Make it fail on purpose by skipping `fitTerminalSafely` after `terminal.open(mount)`, confirm it fails, and restore it. Then log "R43: no tall grid; rows … mount … px" in the phase report and stop. +3. [ ] Defect branch: add `fittedBeforeFirstReset` as in Interfaces, and set it to `true` in the reset branch of `render`. +4. [ ] Run the step 2 command. Expected: PASS. Run `-t "attaches without dimensions when the mount is created with no area"` and `-t "issues one resize for the initial geometry"`. Expected: PASS. +5. [ ] Prove the test can fail: never set the flag's fit branch, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + +### Task 1.10: Count columns by Unicode 11 in every terminal + +- Satisfies: R5 (D4). Every terminal in the client counts columns by Unicode 11, the `@xterm/addon-unicode11` provider. Given a real xterm built with the panel's options, when U+1F600 is written, then the cursor advances 2 columns; before the change it advances 1. +- Depends on: 1.9 +- Files: modify `apps/web/package.json` (add `"@xterm/addon-unicode11": "^0.9.0"` to `dependencies`, beside `@xterm/addon-webgl`); `pnpm-lock.yaml` is updated only by the executor's `vp install`; create `apps/web/src/components/terminalUnicode.ts`; test `apps/web/src/components/terminalUnicode.xterm.test.ts`; modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `Unicode11Addon` from `@xterm/addon-unicode11`, and `Terminal` and `ITerminalOptions` from `@xterm/xterm`. Produces these exports of `terminalUnicode.ts`: + - `export const TERMINAL_UNICODE_VERSION = "11";` + - `export const TERMINAL_BASE_OPTIONS = { cursorBlink: true, macOptionClickForcesSelection: true, lineHeight: 1, fontSize: 12, scrollback: 5_000, allowProposedApi: true } as const satisfies ITerminalOptions;` + - `export function activateTerminalUnicode(terminal: Pick): void`, which loads `new Unicode11Addon()` and sets `terminal.unicode.activeVersion = TERMINAL_UNICODE_VERSION`. + + The panel's `new Terminal({...})` spreads `TERMINAL_BASE_OPTIONS` in place of its five literal options, keeps `fontFamily`, `theme` and `linkHandler`, and calls `activateTerminalUnicode(terminal)` right after `terminal.loadAddon(fitAddon)` and before `terminal.open(mount)`. + + Why `allowProposedApi: true`: in xterm 6.0.0, `node_modules/@xterm/xterm/src/browser/public/Terminal.ts` calls `_checkProposedApi()` in the `unicode` getter. That throws "You must set the allowProposedApi option to true to use proposed API" unless the option is set, and the addon's `activate` registers its provider through `terminal.unicode`. The same option only unlocks four other public members, and changes no behavior by itself: the `markers` getter, `registerCharacterJoiner`, `deregisterCharacterJoiner` and `registerDecoration`. The panel calls none of them today. `OptionsService` defaults it to `false`, and nothing else in xterm's sources reads it. +- Skills: tdd (red-green with a real xterm); vercel-react-best-practices (options moved out of the render path, no new effect) +- Read first: `apps/web/src/components/terminalFollowOutput.xterm.test.ts` (it already builds a real `Terminal` with `allowProposedApi: true` under happy-dom); the `vi.mock("@xterm/xterm", …)` fake and its `loadAddon` in `ThreadTerminalPanel.interactions.test.tsx`; xterm's `src/browser/public/Terminal.ts` (`_checkProposedApi`) +- Tier: standard +- TDD: red-green +- Done when: `vp test run --project unit src/components/terminalUnicode.xterm.test.ts` exits 0, and so do the same command on `src/components/ThreadTerminalPanel.interactions.test.tsx` with `-t "Unicode 11"`, `-t "WebGL"` and `-t "font"`. + +Steps + +1. [ ] Executor step, not the task agent's: add the dependency line to `apps/web/package.json` and run `vp install` from the repository root. Expected: `pnpm-lock.yaml` gains `@xterm/addon-unicode11@0.9.0` and nothing else changes in it. The lockfile is reserved for the executor; a task agent never edits it. +2. [ ] Write `terminalUnicode.xterm.test.ts` (with `// @vitest-environment happy-dom`). Test `it("advances two columns for U+1F600 with the panel's options and one without Unicode 11")`: + - Build `new Terminal({ ...TERMINAL_BASE_OPTIONS, cols: 20, rows: 5 })` and call `activateTerminalUnicode`. + - Write `"\u{1F600}"` and wait for the write callback. Assert `buffer.active.cursorX` is 2. + - Build a second terminal with the same options and no activation, and assert `cursorX` is 1 (the Unicode 6 baseline). + - Assert `terminal.unicode.activeVersion` is `"11"`. +3. [ ] Run `vp test run --project unit src/components/terminalUnicode.xterm.test.ts`. Expected: FAIL, `terminalUnicode` cannot be resolved. +4. [ ] Create `terminalUnicode.ts` with the exports above. Run the step 3 command. Expected: PASS. +5. [ ] In the interaction test, add `vi.mock("@xterm/addon-unicode11", …)`, returning a recorded `Unicode11Addon` class. Give the fake terminal `unicode = { activeVersion: "6" }`. Write `it("activates Unicode 11 on every terminal, with WebGL on or off")` as an `it.each([false, true])` over `webglEnabled`: assert `loadAddon` received the Unicode 11 instance, `unicode.activeVersion` is `"11"`, and with WebGL on `loadAddon` also received `view.webglAddon`. Run `-t "Unicode 11"`. Expected: FAIL, `activeVersion` is `"6"`. +6. [ ] In `ThreadTerminalPanel.tsx`, spread `TERMINAL_BASE_OPTIONS` and call `activateTerminalUnicode(terminal)` as in Interfaces. Run `-t "Unicode 11"`. Expected: PASS. +7. [ ] Check that the renderer and font paths are unchanged: run the interaction test with `-t "WebGL"` and with `-t "font"`. Expected: PASS. That covers the context-loss, fallback and DPR=2 WebGL correction tests and the font-change tests for both renderers. +8. [ ] Prove the test can fail: remove the `activeVersion` assignment in `activateTerminalUnicode`, then run the step 3 command. Expected: FAIL with `cursorX` 1. Restore it and run again. Expected: PASS. +9. [ ] Run `vp check src/components/terminalUnicode.ts src/components/terminalUnicode.xterm.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md new file mode 100644 index 000000000..b2e1b671b --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md @@ -0,0 +1,208 @@ +# Phase 2: Git lock limits, sign-in detector and pull-request lookups + +- Goal: The #354 and #355 items of the spec (R21, R22, R23, R26 to R30) are fixed, recorded as limits in code, or decided by a test that stays as proof, with the server tests green. +- Round: 1 +- Depends on: none +- Owns: `apps/server/src/git/process.rs`, `apps/server/src/git/sign_in.rs`, `apps/server/src/source_control/open_pull_request_lookups.rs`, `apps/server/src/source_control/pull_request.rs` (the file that holds `PullRequestService`), `apps/server/src/git/broadcaster.rs`, `apps/server/src/production/git_vcs.rs`, `apps/server/src/production/runtime.rs` (the quiesce step for lookups only; the spec names it `apps/server/src/runtime.rs`, which does not exist) +- Must not touch: `docs/skills/bibcode-profiler/references/events.md` and `docs/testing/cross-platform-validation.md` (Phase 9 owns every doc edit, including the doc line for the new quiesce step), `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/terminal/**`, `apps/server/src/lifecycle.rs` +- Skills: `tdd` (red-green cycle for each behavior change), `mstack-diagnose` (R30 is decided by a test), `bibcode-profiler` (R26 to R28 touch the stop sequence and logging that profiler coverage watches) +- Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data); `docs/architecture/overview.md` section Profiler (what the stop sequence and `tracing` records must keep covering); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (D5 to D8 are binding); `docs/plans/2026-11-09-issue-batch-340-345-290/spec.md` requirements R15 to R17 (lock ownership rule the comments restate) +- Checkpoint: `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: sign_in:: pr_row_ a_failed_lookup index_lock_cleanup_ quiesce_in_` exits 0 and every listed filter runs at least one test; then `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0 + +Commands use the pinned toolchain (1.98.1) first on PATH, with `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`. Task order matters: 2.4, 2.5 and 2.6 all edit `open_pull_request_lookups.rs`, and 2.6 to 2.8 all edit `git_vcs.rs` and `broadcaster.rs`, so they run one after another. + +Profiler coverage line for the final report: R26 adds a quiesce step before `git-writes` and before the profiler teardown (the step is timed by the existing `steps.mark` mechanism, so a slow step reaches `server.stop` through `slowSteps`); R27 adds one `tracing::warn!` with no URL, path or branch; nothing else in this phase spawns a process, adds an RPC method, removes a worktree or emits an event. + +## Tasks + +### Task 2.1: Record the pull limit at takes_index_lock + +- Satisfies: R21 (D5): `pull` stays out of the lock list and the comment says why. +- Depends on: none +- Files: modify `apps/server/src/git/process.rs` +- Interfaces: consumes `fn takes_index_lock(args: &[OsString]) -> bool` (the `_ => false` arm with the comment "`pull` holds no lock while it fetches"); produces nothing other tasks use +- Skills: none +- Read first: `apps/server/src/git/process.rs` (`takes_index_lock`, `IndexLockWatch::after_forced_stop`, `holds_open`, `WriteSettlement::clean_up`) +- Tier: cheap +- TDD: exempt (comment only, no behavior) +- Done when: `rg -n "shortcut: pull" apps/server/src/git/process.rs` exits 0 and prints the new comment line; `cargo test -p bibcode-server --lib index_lock_cleanup_lists_only_commands_that_take_the_lock -j 2` exits 0 (the existing test already asserts that unlisted commands stay unlisted) + +Steps + +1. [ ] Replace the one-line comment above the `_ => false` arm with a comment that starts `// shortcut: pull is unlisted on purpose (D5),` and states three facts: its merge child, not the root, holds `index.lock`; `holds_open` looks only at the root's process id on Linux and macOS, so a listed `pull` could never pass the ownership check there; on Windows a listed `pull` would arm the 2-second rule during the fetch, which holds no lock, and could remove a foreign lock. End with `upgrade when holds_open covers the process group and Windows can check ownership`. +2. [ ] Run `cargo test -p bibcode-server --lib index_lock_cleanup_lists_only_commands_that_take_the_lock -j 2`. Expected: PASS (1 test). +3. [ ] Run `rg -n "shortcut: pull" apps/server/src/git/process.rs`. Expected: one match. +4. [ ] Run `cargo fmt --all --check`. Expected: no output, exit 0. + +### Task 2.2: Record the hook limit at the forced-stop cleanup + +- Satisfies: R22: the comment says a lock taken while a hook runs is kept on Linux and macOS and that Windows can still remove a foreign lock; the existing test is the proof. +- Depends on: 2.1 (same file) +- Files: modify `apps/server/src/git/process.rs` +- Interfaces: consumes `WriteSettlement::clean_up` (the `cfg!(any(target_os = "linux", target_os = "macos")) && report.before_force != Some(true)` guard) and the test `index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write`; produces nothing +- Skills: none +- Read first: `apps/server/src/git/process.rs` (`holds_open` doc comment: Git closes the lock while a hook or the editor runs) +- Tier: cheap +- TDD: exempt (comment only, no behavior) +- Done when: `rg -n "hook" apps/server/src/git/process.rs | rg "R22"` exits 0 and prints the new comment; `cargo test -p bibcode-server --lib index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write -j 2` exits 0 with 1 test passed + +Steps + +1. [ ] Extend the comment above the `if cfg!(any(target_os = "linux", target_os = "macos")) && report.before_force != Some(true)` guard in `clean_up` with a paragraph tagged `R22`: a write stopped by force while a hook runs holds no open lock, so on Linux and macOS a lock another Git process took meanwhile is kept, proven by `index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write`; the cost is that a lock the killed Git itself left during a hook also stays; on Windows the 2-second rule has no ownership check and can still remove a foreign lock. +2. [ ] Run `cargo test -p bibcode-server --lib index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write -j 2`. Expected: PASS (1 test). +3. [ ] Run `rg -n "R22" apps/server/src/git/process.rs`. Expected: one match. +4. [ ] Run `cargo fmt --all --check`. Expected: exit 0. + +### Task 2.3: Anchor the sign-in detector to Git's fatal line + +- Satisfies: R23: only a line that starts with `fatal:` and holds both phrases counts; a commit message or hook output that holds both phrases elsewhere does not. +- Depends on: none +- Files: modify `apps/server/src/git/sign_in.rs`; test `apps/server/src/git/sign_in.rs` (module `tests`) +- Interfaces: consumes `pub fn has_missing_username_refusal(detail: &str) -> bool` with callers `ExecutionPlace::push_guidance(&self, git_detail: &str) -> Option`, `operations.rs::remote_push_guidance` (passes `error.outputs.last()?.stderr`) and `git_vcs.rs` (passes the wire `detail`); produces the same signature with the anchored rule +- Skills: `tdd` (the detector's behavior changes) +- Read first: `apps/server/src/git/sign_in.rs` (detector and test `detects_the_missing_username_refusal_only_with_prompts_disabled`); `apps/server/src/git/manager/operations.rs` (`remote_push_guidance`); `apps/server/src/production/git_vcs.rs` (the `failed_phase == Some("push")` block in the stacked-action stream) and `apps/server/src/git/repository.rs` (`actionable_git_failure`) +- Tier: standard +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib sign_in:: -j 2` exits 0 with the new test listed as passed + +Steps + +1. [ ] Settle the input question first and write the finding into the doc comment of `has_missing_username_refusal`. Read both callers. Finding from the code read for this plan: neither joins lines. `operations.rs` passes the raw `stderr` of the last Git output. `git_vcs.rs` passes the wire `detail`, which comes from `actionable_git_failure` and is the redacted, trimmed stderr, still multi-line. The `{detail} {note}` join in `git_vcs.rs` is built after the detector runs and is not an input. Git progress output separates updates with a carriage return, so the fatal line can follow a progress update after `\r` and not after `\n`. Re-read both files; if either now joins lines, add that separator to the split in step 4. +2. [ ] Write the failing test `the_missing_username_refusal_needs_a_fatal_line` in `apps/server/src/git/sign_in.rs` asserting: (a) `has_missing_username_refusal("fix: could not read Username and terminal prompts disabled")` is false; (b) a hook output line `hook: could not read username ... terminal prompts disabled` inside a multi-line text is false; (c) a text whose first line is `Writing objects: 50%\rfatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled` is true; (d) `" FATAL: Could not read Username for 'https://gitlab.example.test': Terminal Prompts Disabled\r\n"` is true; (e) a `fatal:` line holding only one phrase is false; (f) the existing `MISSING_USERNAME` constant still passes. +3. [ ] Run `cargo test -p bibcode-server --lib sign_in::tests::the_missing_username_refusal_needs_a_fatal_line -j 2`. Expected: FAIL on assertion (a) or (b) (the old detector accepts them). +4. [ ] Implement in `sign_in.rs`: `detail.split(['\n', '\r']).any(|line| { let line = line.trim_start().to_ascii_lowercase(); line.starts_with("fatal:") && line.contains("could not read username") && line.contains("terminal prompts disabled") })`. Keep `#[must_use]` and the signature. +5. [ ] Run `cargo test -p bibcode-server --lib sign_in:: -j 2`. Expected: PASS, including `detects_the_missing_username_refusal_only_with_prompts_disabled` and the push-guidance tests in `git/manager/operations/sign_in_tests.rs` run by `cargo test -p bibcode-server --lib sign_in_tests -j 2`. +6. [ ] Prove the test can fail: remove the `starts_with("fatal:")` condition, then run `cargo test -p bibcode-server --lib sign_in::tests::the_missing_username_refusal_needs_a_fatal_line -j 2`. Expected: FAIL. Restore the condition and run it again. Expected: PASS. +7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 2.4: Wait for running lookups at quiesce + +- Satisfies: R26: quiesce waits for every running lookup before the profiler teardown; a lookup admitted after shutdown ends at once as cancelled. +- Depends on: none +- Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/source_control/pull_request.rs`; modify `apps/server/src/production/git_vcs.rs`; modify `apps/server/src/production/runtime.rs`; test `apps/server/src/source_control/open_pull_request_lookups.rs` and `apps/server/src/production/runtime.rs` +- Interfaces: + - consumes `OpenPullRequestLookups::admit` (the bare `tokio::spawn` of the shared lookup), `Slot::InFlight { id, lease, result }`, `PullRequestService.open_lookups: Arc`, `tokio_util::task::TaskTracker` + - produces `OpenPullRequestLookups::shutdown(&self) -> impl Future` (`pub(crate) async fn shutdown(&self)`): marks the set closed, cancels the cancellation token of every in-flight lookup, closes a `TaskTracker` and waits for it; safe to call twice; after it, `resolve` returns `None` without calling `load` + - produces `PullRequestService::shutdown_open_pull_request_lookups(&self)` (`pub async fn`), calling the above + - produces `GitVcsRpcServices::pull_requests(&self) -> PullRequestService` (`pub(crate)`, a clone; the runtime calls it after every builder, because `with_hosting_executable_dir_for_integration_test` replaces the service) + - produces the quiesce step name `pull-request-lookups`, marked with `steps.mark("pull-request-lookups")` directly after `steps.mark("status-broadcaster")` and before `worktree-catalog-operations`, so it runs before `git-writes` and before `profiler-end-session`. Phase 9 copies this exact name into `events.md` (position: after `status-broadcaster`) +- Skills: `tdd` (new shutdown behavior), `bibcode-profiler` (stop sequence and teardown order must stay covered) +- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (module doc, `admit`, `settle`, the `Loader` test helper); `apps/server/src/production/runtime.rs` (`quiesce_for_update` from `mcp_control` to `profiler-end-session`, test `index_lock_cleanup_quiesce_stops_a_running_git_write`); `apps/server/src/source_control/pull_request.rs` (`resolve_current_optional_shared`, `run_provider_os_with_allowed_exit_codes`) +- Tier: most capable +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::shutdown_ -j 2` exits 0 with at least 3 tests, and `cargo test -p bibcode-server --lib production::runtime::tests::quiesce_in_ -j 2` exits 0 including the new `quiesce_in_waits_for_a_running_pull_request_lookup` + +Steps + +1. [ ] Settle where the `gh` child is reaped. Finding from the code read for this plan: the flight future calls `PullRequestService::resolve_current_optional`, which awaits `self.runner.run(ProcessRequest { .. }, cancellation)` in `run_provider_os_with_allowed_exit_codes`. The supervised runner kills and reaps the child inside that awaited call when the flight's token is cancelled. So the child is reaped inside the detached flight task, and joining that task is enough; nothing else needs tracking. Record this finding in the doc comment of `OpenPullRequestLookups::shutdown`. Re-read both functions; if the runner instead hands the reap to a background task, join that too and say so in the comment. +2. [ ] Write the failing tests in the `tests` module of `open_pull_request_lookups.rs`: Expected: the tests below compile and are the ones the next step runs. + - `shutdown_waits_for_a_lookup_that_ends_late`: a loader that, once its flight token is cancelled, waits 200 ms (use `tokio::time::sleep`, paused clock, `#[tokio::test(start_paused = true)]`) and then sets an `ended` flag; start `resolve` on a spawned task, wait for the run, call `lookups.shutdown().await`; assert `ended` is true when `shutdown` returns. + - `shutdown_cancels_a_lookup_nobody_leaves`: the waiter is still waiting when `shutdown` is called; assert the loader saw its cancellation and the waiter got `None`. + - `a_lookup_admitted_after_shutdown_ends_as_cancelled`: after `shutdown`, `resolve` returns `None` and `loader.runs() == 0`. +3. [ ] Write the failing runtime test `quiesce_in_waits_for_a_running_pull_request_lookup` in the `tests` module of `production/runtime.rs`, copying the shape of `index_lock_cleanup_quiesce_stops_a_running_git_write`: start a lookup through `runtime`'s `GitVcsRpcServices::pull_requests()` with a fake forge command that ignores its first TERM for 200 ms, call `runtime.quiesce_for_update().await`, assert the fake command has exited and `quiesce_for_update` returned `Ok`. Gate with `#[cfg(unix)]` like its neighbors. +4. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::shutdown_ -j 2`. Expected: FAIL to compile (`no method named shutdown`). Add a stub `pub(crate) async fn shutdown(&self) {}` and run again. Expected: FAIL on `ended` being false. +5. [ ] Implement in `open_pull_request_lookups.rs`: add `tasks: TaskTracker` and a `closed` flag in `State`; store each flight's `CancellationToken` in `Slot::InFlight`; spawn the shared lookup with `self.tasks.spawn(..)` instead of `tokio::spawn(..)` (the uncoalesced no-room path goes through the same spawn); in `admit`, when `closed` is true return a new `Admission::Closed`, which `resolve` maps to `None` before `load` is called; `shutdown` sets `closed`, cancels every in-flight token, calls `tasks.close()`, then `tasks.wait().await`. Add `PullRequestService::shutdown_open_pull_request_lookups` in `pull_request.rs` and `GitVcsRpcServices::pull_requests` in `git_vcs.rs`. +6. [ ] Implement in `runtime.rs`: add a field `pull_requests: crate::source_control::PullRequestService` to the runtime struct, filled from `git_vcs.pull_requests()` after the last builder call; in `quiesce_for_update`, after `self.status_broadcaster.shutdown().await; steps.mark("status-broadcaster");` add `self.pull_requests.shutdown_open_pull_request_lookups().await; steps.mark("pull-request-lookups");`. Order after the status broadcaster is deliberate: streams that hold leases are stopped first, so the cancelled lookups have no waiters left. +7. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2` and `cargo test -p bibcode-server --lib production::runtime::tests::quiesce_in_ -j 2`. Expected: PASS. Also run `cargo test -p bibcode-server --lib production::git_vcs::tests::a_lookup_past_the_refresh_bound_stops_at_quiesce -j 2`. Expected: PASS. +8. [ ] Prove the tests can fail: replace `self.tasks.spawn(..)` with `tokio::spawn(..)`, then run `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::shutdown_waits_for_a_lookup_that_ends_late -j 2`. Expected: FAIL. Restore it and run again. Expected: PASS. +9. [ ] Check the order in the source: `rg -n 'steps.mark\("(status-broadcaster|pull-request-lookups|git-writes|profiler-end-session)"\)' apps/server/src/production/runtime.rs`. Expected: the four names in exactly that order. +10. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 2.5: Remember a failed lookup for 5 seconds + +- Satisfies: R27 (D6): the same key answers with the failure for 5 seconds without a new lookup; a cancelled lookup is never remembered as failed; an invalidation clears it; the failure is logged once at `warn` without the remote's URL. +- Depends on: 2.4 (same file and the same `Slot` enum) +- Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/production/git_vcs.rs` (test `a_failed_lookup_is_not_remembered_as_no_pull_request` only); test `apps/server/src/source_control/open_pull_request_lookups.rs` +- Interfaces: consumes `Slot`, `settle(&self, key: &LookupKey, id: u64, outcome: &LookupResult)`, `LookupResult = Result, SourceControlProviderError>` (the error is `Clone`); produces `pub(crate) const OPEN_PULL_REQUEST_FAILURE_BACKOFF: Duration = Duration::from_secs(5)`, a new variant `Slot::Failed { error: SourceControlProviderError, expires_at: Instant }`, a new variant `Admission::Failed(SourceControlProviderError)` that `resolve` returns as `Some(Err(error))`. `with_limits(ttl, capacity)` keeps its signature +- Skills: `tdd` (new backoff behavior), `bibcode-profiler` (the warn record must hold no URL, path or branch) +- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (`admit`, `settle`, `make_room`, test `failures_are_shared_with_waiters_but_never_stored`); `apps/server/src/production/git_vcs.rs` (test `a_failed_lookup_is_not_remembered_as_no_pull_request`) +- Tier: most capable +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2` exits 0 with the new tests listed, and `cargo test -p bibcode-server --lib production::git_vcs::tests::a_failed_lookup -j 2` exits 0 + +Steps + +1. [ ] Write the failing tests in `open_pull_request_lookups.rs`, all with `#[tokio::test(start_paused = true)]`: + - `a_failed_lookup_answers_from_memory_for_five_seconds`: first `resolve` returns `Some(Err(failure()))` with `loader.runs() == 1`; a second `resolve` at +4 s returns `Some(Err(failure()))` with `runs() == 1`; at +5 s plus 1 ms a third `resolve` runs the loader again (`runs() == 2`). + - `a_cancelled_lookup_is_not_remembered_as_failed`: a gated loader whose waiter is cancelled so the flight token is cancelled and the loader returns `Err(failure())`; the next `resolve` runs the loader (`runs() == 2`). + - `an_invalidation_clears_a_remembered_failure`: after a failure, `lookups.invalidate(path)`, then `resolve` runs the loader again. + - `a_failed_lookup_warns_once_without_the_remote_address`: capture `tracing` output at `WARN` with `tracing::subscriber::set_default` and a local `CapturedLog` writer (copy the shape used in the `tests` module of `production/runtime.rs`); a failure whose `detail` and `cwd` hold `https://gitlab.example.test/example-group/sample-project` and `/work/sample-project` is resolved twice inside the backoff; assert exactly one WARN line, and that it contains neither `gitlab.example.test` nor `sample-project`. +2. [ ] Update the existing test `failures_are_shared_with_waiters_but_never_stored`: it must keep proving that waiters share one run, and its last assertion changes from "a failure must not be remembered" to `recovered.runs() == 0` inside 5 s, then `recovered.runs() == 1` after `tokio::time::advance(Duration::from_secs(5))`. Update `a_failed_lookup_is_not_remembered_as_no_pull_request` in `git_vcs.rs` to call `fixture.services.pull_requests.invalidate_open_pull_request_lookups(&fixture.repository)` between `clear("fail")` and the second refresh (a push, a mutation or a local change does this in production); its name and meaning stay: a failure is not stored as "no pull request". Expected: both updated tests compile and fail in the next run until the backoff exists. +3. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2`. Expected: FAIL on `a_failed_lookup_answers_from_memory_for_five_seconds` with `left: 2, right: 1` (the second call ran the loader). +4. [ ] Implement: in `settle`, take `cancelled: bool` (the flight token's `is_cancelled()` read in the spawned task before `settle`); for `Err(error)` with `!cancelled` and the key still owned by this flight, set `Slot::Failed { error: error.clone(), expires_at: Instant::now() + OPEN_PULL_REQUEST_FAILURE_BACKOFF }`; with `cancelled`, remove the entry as today. In `admit`, a `Slot::Failed` whose `expires_at > now` returns `Admission::Failed(error.clone())`; an expired one falls through to a new lookup. In `make_room`, keep an unexpired `Failed` entry and count it among the entries removed by expiry or by the closest-to-expiring rule. Log in the spawned task, once per lookup that ended with an uncancelled `Err`: `tracing::warn!(provider = ?key.provider, "open pull-request lookup failed; answering with the failure for 5 seconds")`. Do not log the error, cwd, reference or command. +5. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_ -j 2`. Expected: PASS (`pr_row_a_failed_lookup_keeps_the_pull_request` still passes: the stream shows the request after a failure). +6. [ ] Prove the tests can fail: make `settle` store `Slot::Failed` for a cancelled flight too, then run `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::a_cancelled_lookup_is_not_remembered_as_failed -j 2`. Expected: FAIL. Restore it and run again. Expected: PASS. +7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 2.6: Stop showing a remembered pull request after a failed lookup on stale data + +- Satisfies: R28 (D7): a failed lookup, with the last success for that branch more than 5 minutes old, clears the remembered request; without a failed lookup it stays however old; a ref change clears it as today. +- Depends on: 2.5 (the failure signal is the same; both edit `git_vcs.rs`) +- Files: modify `apps/server/src/git/broadcaster.rs`; modify `apps/server/src/production/git_vcs.rs`; test `apps/server/src/git/broadcaster.rs` +- Interfaces: + - consumes `GitBroadcaster::record_pull_request(&self, cwd: &Path, ref_name: Option<&str>, pull_request: Option)`, `known_pull_request`, `RepositoryState`, `clear_remote_for_ref_change`, and in `git_vcs.rs` `lookup_open_pull_request(...) -> Option>` whose `None` means failed or cancelled + - produces `RepositoryState.pr_recorded_at: Option`, set by `record_pull_request` on every success (including "no open request") and cleared in `clear_remote_for_ref_change` + - produces `pub(crate) const PULL_REQUEST_STALE_AFTER: Duration = Duration::from_secs(300)` in `broadcaster.rs` + - produces `pub(crate) fn note_failed_pull_request_lookup(&self, cwd: &Path, ref_name: Option<&str>)`: when `ref_name` matches, `pr_recorded_at` is older than `PULL_REQUEST_STALE_AFTER` and a request is remembered, sets the remembered `remote.pr` to `None`; then it requests a republish the way `notify_pull_request_change` does so streams show the cleared state +- Skills: `tdd` (new expiry behavior) +- Read first: `apps/server/src/git/broadcaster.rs` (`record_pull_request`, `known_pull_request`, `keep_pull_request`, `clear_remote_for_ref_change`, test `a_snapshot_under_a_new_epoch_keeps_the_known_pull_request`); `apps/server/src/production/git_vcs.rs` (the `Ok(None)` and `Err(_)` arms of `vcs.refreshStatus`, and the `found` arm of the status stream loop) +- Tier: most capable +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_ -j 2` exits 0 with the new tests listed, and `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_row_ -j 2` exits 0 + +Steps + +1. [ ] Decide how the callers tell failure from cancellation, and write it in the doc comment of `note_failed_pull_request_lookup`. In the status stream, `found` is `Ok(None)` for both a failed lookup and one cancelled by `task_token`; count it as a failure only when `finished.cancellation.is_cancelled()` is false. In `vcs.refreshStatus`, the inner lookup runs under a token nothing cancels, so `Ok(None)` from the timeout wrapper's inner result is a failure; the outer `cancellation.cancelled()` arm produces `Ok(None)` of the select (not the lookup) and is not a failure. Re-read both arms and adjust if the shapes differ. +2. [ ] Write the failing tests in `broadcaster.rs` (`#[tokio::test(start_paused = true)]`, copying the setup of `a_snapshot_under_a_new_epoch_keeps_the_known_pull_request`): + - `a_failed_lookup_clears_a_request_recorded_six_minutes_ago`: record a request, `tokio::time::advance(360 s)`, call `note_failed_pull_request_lookup`; `known_pull_request` returns `None`. + - `a_failed_lookup_keeps_a_request_recorded_four_minutes_ago`: same with 240 s; still `Some`. + - `a_request_recorded_six_minutes_ago_stays_without_a_failed_lookup`: advance 360 s and call only `known_pull_request`; `Some`. + - `a_failed_lookup_for_a_former_branch_clears_nothing`: `ref_name` differs; still `Some`. + - `a_ref_change_clears_the_recorded_time`: after `clear_remote_for_ref_change`, a later failure on the new ref has no stale stamp to compare (no request shown, no panic). +3. [ ] Run `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_ -j 2`. Expected: FAIL to compile (`no method named note_failed_pull_request_lookup`). Add a stub that does nothing and run again. Expected: FAIL on the six-minute test (the request is still `Some`). +4. [ ] Implement in `broadcaster.rs` the field, the constant and the method from Interfaces; `record_pull_request` sets `entry.pr_recorded_at = Some(Instant::now())` under the same `ref_name` filter it already applies. `keep_pull_request` needs no change: it copies a cached request only when the new remote has none, and the cache now holds `None` after expiry. +5. [ ] Call `note_failed_pull_request_lookup` from both `git_vcs.rs` sites chosen in step 1, in the stream arm before `shown.reconcile`, and in `vcs.refreshStatus` where `Ok(None)` is matched. +6. [ ] Add a stream test next to `pr_row_a_failed_lookup_keeps_the_pull_request` named `pr_row_a_failed_lookup_after_six_minutes_clears_the_pull_request`: `Fixture` with a paused or advanced clock, the subscriber shows request 42, advance 6 minutes, `fixture.set("fail", "")`, `fixture.services.pull_requests.invalidate_open_pull_request_lookups(..)`, `fixture.report_pull_request_change()`; the subscriber later shows `pr().is_null()`. If `Fixture` cannot advance the broadcaster's clock, add a `#[cfg(test)]` setter `set_pull_request_recorded_at_for_test(&self, cwd, Instant)` on `GitBroadcaster` and use it. +7. [ ] Run `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_ -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_row_ -j 2`. Expected: PASS. +8. [ ] Prove the tests can fail: change the comparison to `>=` the wrong way (clear when the stamp is younger than 300 s), then run `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_keeps_a_request_recorded_four_minutes_ago -j 2`. Expected: FAIL. Restore it and run again. Expected: PASS. +9. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 2.7: Record the limit at spawn_for_subscribers + +- Satisfies: R29 (D8): the doc comment states that a late lookup with no status subscriber is dropped on purpose. +- Depends on: 2.6 (same file) +- Files: modify `apps/server/src/git/broadcaster.rs` +- Interfaces: consumes `pub(crate) fn spawn_for_subscribers(&self, cwd: &Path, task: impl Future + Send + 'static)`; produces nothing +- Skills: none +- Read first: `apps/server/src/git/broadcaster.rs` (`spawn_for_subscribers`); `apps/server/src/production/git_vcs.rs` (its only caller, the `Err(_)` arm of `vcs.refreshStatus`) +- Tier: cheap +- TDD: exempt (comment only, no behavior) +- Done when: `rg -n "D8" apps/server/src/git/broadcaster.rs` exits 0 and prints the new comment line + +Steps + +1. [ ] Extend the doc comment of `spawn_for_subscribers` with: `shortcut: with no status subscriber the late lookup is dropped (D8). Nothing would show its answer, and the next refresh asks again. A refresh with no subscriber therefore never warms the remembered request. Upgrade only if a caller needs the answer without a stream.` +2. [ ] Run `rg -n "D8" apps/server/src/git/broadcaster.rs`. Expected: one match. +3. [ ] Run `cargo fmt --all --check`. Expected: exit 0. + +### Task 2.8: Decide by test whether a status-neutral local change leaves a stale answer + +- Satisfies: R30: a stream test invalidates during a lookup with a local change that alters no status; if the pre-invalidation answer is shown with no follow-up lookup, a follow-up lookup is started; otherwise the test stays as proof. +- Depends on: 2.6 (same files) +- Files: modify `apps/server/src/production/git_vcs.rs` (test; code only in the defect branch); modify `apps/server/src/git/broadcaster.rs` (defect branch only); test `apps/server/src/production/git_vcs.rs` +- Interfaces: consumes `Fixture`, `subscriber_showing_the_pull_request`, `wait_for_lookups`, `fixture.set`, `fixture.clear`, `GitBroadcaster::notify_local_change(&self, cwd: &Path)` (async), `GitBroadcaster::notify_pull_request_change(&self, cwd: &Path)` (async); produces the test `a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer` +- Skills: `mstack-diagnose` (the test decides between defect and no defect), `tdd` (defect branch only) +- Read first: `apps/server/src/production/git_vcs.rs` (test `a_local_change_during_a_lookup_replaces_its_outdated_answer`, which writes `saved.txt` and so changes status; `observe_local_changes`; the `publication` arm of the status stream loop that sets `look_again`); `apps/server/src/git/broadcaster.rs` (`notify_local_change`, `reconcile_remote_after_local_publication`) +- Tier: most capable +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer -j 2` exits 0, and the outcome line (defect fixed or no defect) is written in the pull request notes for #355 + +Steps + +1. [ ] Write the test by copying `a_local_change_during_a_lookup_replaces_its_outdated_answer` with one change: after `fixture.clear("open")` do not write `saved.txt`; call `fixture.services.broadcaster.notify_local_change(&fixture.repository).await` with the working tree untouched, so the local status is byte-identical. Assert that within the helper's wait the subscriber's `pr()` becomes null and `fixture.lookups()` equals `["main", "main", "main"]`. +2. [ ] Run `cargo test -p bibcode-server --lib production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer -j 2`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. +3. [ ] If the result is PASS: this is the no-defect branch. The status owner publishes a neutral change, or the stream loop starts a follow-up lookup, so nothing changes in code. Keep the test, add the sentence `Decided by test (R30): no defect; a status-neutral local change still starts one follow-up lookup.` to the test's doc comment, and stop. +4. [ ] If the result is FAIL because the subscriber keeps showing request 42 and the lookup count stays at 2: this is the defect branch (a neutral change publishes nothing, so `look_again` is never set). Fix at the first wrong decision: in `observe_local_changes` and the local-change closure in `GitVcsRpcServices`, after `invalidate_open_pull_request_lookups`, call `broadcaster.notify_pull_request_change(cwd)` for the observer path as the closure at the `handle.spawn` site already does, so every invalidation also asks each stream to look again. Re-run the test. Expected: PASS. +5. [ ] In the defect branch also run `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_ -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::a_ -j 2`. Expected: PASS (no other stream test now counts an extra lookup; if one does, change its expected `fixture.lookups()` only where the extra lookup follows a real invalidation). +6. [ ] In the defect branch prove the test can fail: remove the added `notify_pull_request_change` call, then run the test. Expected: FAIL. Restore it and run again. Expected: PASS. +7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md new file mode 100644 index 000000000..da72155ab --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md @@ -0,0 +1,132 @@ +# Phase 3: Worktree creation records and the removal drain test + +- Goal: the creation-record fixes (R31 to R34) and the deterministic drain test (R13) land with failing-first tests, an audit event for both creation-rollback arms, and the recorded limits as comments. +- Round: 1 +- Depends on: none +- Owns: `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/diagnostics/profiler/worktree_audit.rs`, `apps/server/src/persistence/repositories.rs`, `apps/server/tests/repositories.rs`, `apps/server/src/production/orchestration_effects.rs` (only the caller of `record_worktree_creation`), `apps/server/tests/profiler_worktree_audit.rs`, `apps/server/src/mcp/tools/worktrees.rs` (header comment only) +- Must not touch: `docs/skills/bibcode-profiler/references/events.md` (Phase 9 documents `create-rollback`), `apps/server/src/git/**`, `apps/server/src/source_control/**`, `apps/server/src/runtime.rs` +- Skills: `tdd` (every behavior task is red-green), `mstack-diagnose` (the flaky test task needs a proven cause), `bibcode-profiler` (the audit event and the Profiler coverage line) +- Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data), `docs/architecture/overview.md` section "Profiler" (events and findings rules), `spec.md` in this plan folder (D9, D10, R13, R31 to R34) +- Checkpoint: `cargo test -p bibcode-server --lib worktree` and `cargo test -p bibcode-server --test repositories` and `cargo test -p bibcode-server --test profiler_worktree_audit` exit 0; `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0 + +Writers set `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3` and put the pinned 1.98.1 toolchain first on PATH. All tasks edit `worktree_catalog_rpc.rs`, so one writer runs them in order. The 50 loaded runs and the 12 Windows VM suite runs for R13 belong to Phase 8. + +## Tasks + +### Task 3.1: Make the drain test wait on the condition + +- Satisfies: R13. Scenario: the test passes when the removal is delayed 2.5 seconds before its drain, and the old test fails there with `bounded admission drain: Elapsed`. +- Depends on: none +- Files: modify `apps/server/src/production/worktree_catalog_rpc.rs` (test `removal_refuses_when_an_admission_does_not_drain_before_the_deadline`; one temporary line in `remove_worktree_owned`) +- Interfaces: consumes `WorktreeCatalogRpcServices.removal_admission_timeout: Duration` and `removal_drain_entered: Option>` (both `#[cfg(test)]` hooks, set on `fixture.handler.services`), `RemovalFixture::{new, set_live_sessions, payload, assert_refused}`, `remove_worktree(&services, request("worktree.remove", payload), CancellationToken)`; produces nothing for other tasks. +- Skills: `mstack-diagnose` (prove the cause before changing the bound), `tdd` (red-green with an injected delay) +- Read first: `apps/server/src/production/worktree_catalog_rpc.rs` tests `removal_waits_for_admitted_work_before_checking_the_session` (the pattern to copy: entered semaphore, spawned removal, 30 s guards) +- Tier: most capable +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib removal_refuses_when_an_admission_does_not_drain_before_the_deadline` exits 0 and the temporary delay is gone (`rg -n "2500" apps/server/src/production/worktree_catalog_rpc.rs` finds nothing). + +Cause, already confirmed: the 2 s bound is the test's own `tokio::time::timeout(Duration::from_secs(2), fixture.remove(payload))`. Production's drain timeout is already injected as 20 ms. The 2 s wraps the whole removal (plan, Git, SQLite) before the drain, so a loaded machine exceeds it. No existing hook delays the removal, and Tokio paused time does not fit because the fixture uses real SQLite and Git subprocesses. The delay is therefore a temporary edit, as in step 1. + +Steps + +1. [ ] Add the temporary delay. In `remove_worktree_owned`, inside the `#[cfg(test)]` block just before `if let Some(entered) = &services.removal_drain_entered`, add the line `#[cfg(test)] tokio::time::sleep(Duration::from_millis(2500)).await;` (use the full path if `Duration` is not imported there). Do not commit it. +2. [ ] Run `cargo test -p bibcode-server --lib removal_refuses_when_an_admission_does_not_drain_before_the_deadline`. Expected: FAIL with `bounded admission drain: Elapsed(())`. This is the red proof for the OLD test. +3. [ ] Rewrite the test body, keeping the 20 ms `removal_admission_timeout`. Create `entered = Arc::new(Semaphore::new(0))` and set `fixture.handler.services.removal_drain_entered = Some(Arc::clone(&entered))`. Hold the admission as before. Spawn `remove_worktree` on a cloned `services` (as the sibling test does). Wait with `tokio::time::timeout(Duration::from_secs(30), entered.acquire())` and `.forget()` the permit. Then await the join handle inside `tokio::time::timeout(Duration::from_secs(30), ...)`, message "removal ends after the drain deadline". Pass the result to `fixture.assert_refused`. The 30 s guards are reached only by a real hang. +4. [ ] Run the same command with the delay still in place. Expected: PASS (it takes about 2.5 s). This is the green proof for the NEW test. +5. [ ] Remove the temporary delay line. Run `rg -n "2500" apps/server/src/production/worktree_catalog_rpc.rs`. Expected: no output. +6. [ ] Run the test 20 times alone: `for i in $(seq 20); do cargo test -p bibcode-server --lib removal_refuses_when_an_admission_does_not_drain_before_the_deadline || break; done`. Expected: 20 passes. Log the run count in the final report. The 50 runs under CPU load and the 12 Windows suite runs are Phase 8. +7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 3.2: Delete creation records by id + +- Satisfies: R31. Scenario: a worktree recreated at the same path between resolve and delete keeps its new record. +- Depends on: none (run after 3.1: same file) +- Files: modify `apps/server/src/persistence/repositories.rs`, `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/tests/repositories.rs`, `apps/server/src/production/orchestration_effects.rs` (caller only, if it needs any change); test the same files +- Interfaces: consumes `Repositories::reap_worktree_creation_record(&self, record_id: i64) -> Result<()>`, `Repositories::list_worktree_creation_records`. Produces: `Repositories::record_worktree_creation(&self, project_cwd: &str, worktree_path: &str) -> Result` (the new row's id, from `last_insert_rowid()` read in the same `database.call` closure as the `INSERT OR REPLACE`); `Repositories::worktree_creation_record_id(&self, worktree_path: &str) -> Result>`; the struct field `ResolvedRemovalThread.creation_record_id: Option` replaces `creation_record: String`. `delete_worktree_creation_record` is removed, with its repository test and its name in the list in `apps/server/tests/repositories.rs`; `worktree_creation_record_id` is added to that list. +- Skills: `tdd` (failing test before the signature change) +- Read first: `apps/server/src/persistence/repositories.rs` tests `worktree_creation_record_delete_removes_only_that_path` and the reap-by-id test just above it (the model for the id test) +- Tier: most capable +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib creation_record` and `cargo test -p bibcode-server --test repositories` exit 0, and `rg -n "delete_worktree_creation_record" apps/server` finds nothing. + +Steps + +1. [ ] Write the failing repository test `worktree_creation_record_id_names_the_current_record` in `repositories.rs`. Record `/worktrees/feature-a`; assert `worktree_creation_record_id` returns `Some(id)` equal to the value `record_worktree_creation` returned. Record the same path again; assert the new id differs. Reap the old id; assert the later record remains. +2. [ ] Write the failing handler test `removal_keeps_a_record_created_while_it_drains` next to `worktree_creation_record_follows_create_and_remove`. Use `RemovalFixture` with no live session and `removal_drain_entered` set as in task 3.1. Call `record_worktree_creation` for the fixture's main checkout and `fixture.linked` path first. Hold an admission with `registry.acquire_admission("removed-thread", [fixture.linked.as_path()])`, spawn the removal, wait on `entered`, then call `record_worktree_creation` for the same path again and remember the new id. Drop the admission, join the removal, and assert it succeeded and `list_worktree_creation_records` still holds the new `record_id`. This is the deterministic gap. +3. [ ] Run `cargo test -p bibcode-server --lib creation_record` and `cargo test -p bibcode-server --test repositories`. Expected: FAIL to compile or fail on the new signature and, for the handler test, FAIL because the path delete removes the new record. +4. [ ] Implement the repository changes above. In `resolve_removal_thread`, call `worktree_creation_record_id(&persisted_path.to_string_lossy())` and store the result in `creation_record_id`. In `remove_worktree_owned`, replace the delete with `reap_worktree_creation_record(id)` when `Some(id)`, keeping the `tracing::warn!` text. +5. [ ] In `create_managed_worktree_locked`, keep the id that `record_worktree_creation` now returns (`let record_id = ...?`-style, keeping the existing error arm). In the dispatch-failure arm, replace `delete_worktree_creation_record(&created.worktree.path)` with `reap_worktree_creation_record(record_id)`. Confirm `orchestration_effects.rs` still compiles (`if ... && let Err(error) = ...await` ignores the new `i64`); change it only if the compiler requires. +6. [ ] Run the commands from step 3. Expected: PASS. +7. [ ] Prove the test can fail: temporarily change the SQL in `reap_worktree_creation_record` to `DELETE FROM worktree_creation_records WHERE worktree_path = (SELECT worktree_path FROM worktree_creation_records WHERE record_id = ?)` (a by-path delete in disguise), then run `cargo test -p bibcode-server --lib removal_keeps_a_record_created_while_it_drains`. Expected: FAIL. Restore the SQL and run it again. Expected: PASS. +8. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 3.3: Audit both creation-rollback arms + +- Satisfies: R32. Scenario: a creation whose record write fails, and one whose dispatch fails, each emit one `worktree.removal` event with mode `create-rollback`. +- Depends on: 3.2 (same function and arms) +- Files: modify `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/mcp/tools/worktrees.rs` (header comment); test `apps/server/src/production/worktree_catalog_rpc.rs` and `apps/server/tests/profiler_worktree_audit.rs` +- Interfaces: consumes `worktree_audit::record_removal_outcome(mode: &str, outcome: Result<(), &str>, duration: Duration, timed_out_drain: bool, cancelled: bool)`, `GitRepository::rollback_managed_worktree_creation` (its `Err` carries `.detail`). Produces: the mode string `create-rollback` and exactly two reason tokens. A rollback that succeeded records `Ok(())` (event only, `ok: true`, `reason: null`, no finding). A rollback that failed records `Err("internal")` (event with `ok: false`, `reason: "internal"`, plus an Error `worktree.removal-failed` finding). Never pass the rollback detail or a path. Phase 9 documents these exact strings in `events.md`. +- Skills: `tdd` (events asserted before the calls exist), `bibcode-profiler` (event shape and coverage rules) +- Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` functions `record_removal_outcome` and `removal_failure` (which reasons raise findings); `apps/server/tests/profiler_worktree_audit.rs` (how events are read: `shutdown_and_read_events`, the `worktree.removal` filter) +- Tier: standard +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib create_rollback` and `cargo test -p bibcode-server --test profiler_worktree_audit` exit 0. + +Settled before writing: no existing test forces the dispatch-failure arm. The record-write arm is already forced by `a_creation_whose_record_cannot_be_written_fails_and_leaves_no_worktree` (it drops the `worktree_creation_records` table). Inject the dispatch failure like this: first create a thread with id `dup-thread` through `fixture.engine.dispatch(OrchestrationCommand::ThreadCreate { ... })` (copy the command from `create_thread` in `RemovalFixture`), then call `create_managed(&fixture, "dup", "feature/dup")` after `git branch feature/dup`. `managed_create_payload` builds the thread id as `"{command_id}-thread"`, so use command id `dup-thread`'s prefix: create the thread `dup-thread` and the command id `dup`. The engine refuses with "Thread 'dup-thread' already exists", which takes the dispatch-failure arm. + +Steps + +1. [ ] Add a pure helper test target: in `worktree_catalog_rpc.rs` declare `fn create_rollback_outcome(rolled_back: bool) -> Result<(), &'static str>` returning `Ok(())` for true and `Err("internal")` for false. Write the failing lib test `create_rollback_outcome_uses_fixed_tokens` asserting both values. +2. [ ] Write the failing handler test `a_dispatch_failure_rolls_back_the_worktree_and_its_record`: with the injection above, assert `create_managed` returns the error, the worktree folder is gone, `creation_records(&fixture)` is empty, and the `git worktree list` inventory holds only the primary checkout (copy the assertions of the record-write failure test). +3. [ ] Write the failing integration test `a_creation_rollback_is_audited` in `tests/profiler_worktree_audit.rs`, using the same harness and `request`/`success_value` helpers as the removal test there. Send `worktree.createManaged` with a thread id that already exists, shut down, read events, and assert exactly one `worktree.removal` event with `mode == "create-rollback"`, `ok == true`, `reason == null`, and no `worktree.removal-failed` finding for it. If the harness cannot drive `worktree.createManaged`, assert the same through the lib test with the profiler enabled the way existing lib tests in `worktree_audit.rs` enable it, and state that in the final report. +4. [ ] Run `cargo test -p bibcode-server --lib create_rollback` and `cargo test -p bibcode-server --test profiler_worktree_audit`. Expected: FAIL (helper missing, no event recorded). +5. [ ] Implement the helper. In both rollback arms of `create_managed_worktree_locked`, after `rollback_managed_worktree_creation` returns and before the error is built, call `record_removal_outcome("create-rollback", create_rollback_outcome(rollback.is_ok()), started.elapsed(), false, cancellation.is_cancelled())` with `started = Instant::now()` taken before the rollback. Update the header comment of `mcp/tools/worktrees.rs` to say a failed creation's rollback records a `create-rollback` outcome too. +6. [ ] Run the commands from step 4. Expected: PASS. +7. [ ] Prove the test can fail: comment out the call in the dispatch arm, run `cargo test -p bibcode-server --test profiler_worktree_audit a_creation_rollback_is_audited`. Expected: FAIL. Restore it and run again. Expected: PASS. +8. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 3.4: Reap a record only when its parent directory exists + +- Satisfies: R33 and D9. Scenario: a record whose folder is missing under a missing parent directory (an unmounted volume) is kept; one whose parent exists is reaped. +- Depends on: none +- Files: modify `apps/server/src/diagnostics/profiler/worktree_audit.rs`; test the same file (module `tests`, next to `gone_after_read`) +- Interfaces: consumes `resolve(owned, complete, listings, registrations, evidence: Evidence, cancel) -> Comparison` and `reap_gone_records(&Repositories, Vec)`. Produces: `fn record_folder_is_gone(worktree_path: &str) -> bool`, true only when `std::fs::symlink_metadata(worktree_path)` fails with `NotFound` AND the path's parent exists as a directory (`Path::parent()` is `Some` and `std::fs::metadata(parent)` succeeds with `is_dir()`). A path with no parent returns false. Both places that judge a record gone call it: the `Err(NotFound)` arm in `resolve`, and the re-check at the top of `reap_gone_records` (which runs it on `tokio::task::spawn_blocking`; a join error reads as not gone). The gate is not `git worktree list`, which succeeds from a readable repository when only the worktree's volume is unmounted (D9). Cost to record in the doc comment: records under a managed directory deleted as a whole are never reaped. +- Skills: `tdd` (failing cases before the gate) +- Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` `resolve`, `reap_gone_records`, and the tests `gone_after_read` and `recorded_paths` (the seam and the helpers to copy) +- Tier: standard +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib worktree_audit` exits 0. + +Steps + +1. [ ] Write the failing test `a_record_under_a_missing_parent_is_kept`: record `/gone/wt` where `/gone` does not exist, call `gone_after_read`, assert it is empty. In the same test call `reap_gone_records` with that record and assert `recorded_paths` still lists it. +2. [ ] Write the failing test `a_record_whose_folder_is_missing_under_an_existing_parent_is_reaped`: record `/wt` with `` present and `wt` absent; assert `gone_after_read` holds it and `reap_gone_records` deletes it. +3. [ ] Write the failing test `a_record_is_kept_when_its_parent_vanishes_before_the_reap`: take the gone list for a record with an existing parent, remove the parent directory, then call `reap_gone_records`; assert the record remains (the re-check). +4. [ ] Run `cargo test -p bibcode-server --lib worktree_audit`. Expected: FAIL on tests 1 and 3 (the old code reaps on `NotFound` alone); test 2 passes. +5. [ ] Implement `record_folder_is_gone` and call it from both places. +6. [ ] Run the command from step 4. Expected: PASS. +7. [ ] Prove the test can fail: make `record_folder_is_gone` ignore the parent check, run the command. Expected: tests 1 and 3 FAIL. Restore and run again. Expected: PASS. +8. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 3.5: Record the limits in comments + +- Satisfies: R34 and D10. Scenario: the four limits are written where the code has them. +- Depends on: 3.4 (same file) +- Files: modify `apps/server/src/diagnostics/profiler/worktree_audit.rs` +- Interfaces: consumes `worktree_evidence` and `opens_as_git_pointer_file`; produces comments only. +- Skills: none +- Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` `worktree_evidence`, `opens_as_git_pointer_file` +- Tier: cheap +- TDD: exempt (comment only, no behavior) +- Done when: `rg -n "shortcut:" apps/server/src/diagnostics/profiler/worktree_audit.rs` finds the three comments below, and `cargo fmt --all --check` exits 0. + +Steps + +1. [ ] On `worktree_evidence`, add a comment starting `shortcut:` saying a creation record has no folder identity, so it can outlive its worktree when another folder takes the same path, and that folder is then reported with `CreationRecord` evidence; upgrade only if that warning proves noisy, by storing an identity at creation (a schema change, D10). +2. [ ] On `worktree_evidence`, add a comment starting `shortcut:` saying a worktree whose record write never happened (a crash between Git's success and the write) is still recognized by its `.git` pointer file (`GitPointerFile`). +3. [ ] On `opens_as_git_pointer_file`, add a comment starting `shortcut:` saying the file is opened first and checked through the opened handle (no check-then-open gap), and that `O_NOFOLLOW` or `FILE_FLAG_OPEN_REPARSE_POINT` protect only the last path component, so a swapped parent directory is not contained; upgrade by reading through `workspace/beneath.rs` `Dir` if the audit ever stops being advisory (D10). +4. [ ] Run `rg -n "shortcut:" apps/server/src/diagnostics/profiler/worktree_audit.rs`. Expected: the three comments. Run `cargo fmt --all --check`. Expected: no errors. + +## Profiler coverage line for the final report + +Touches: RPC methods (no new method; `worktree.createManaged` gains `worktree.removal` events with mode `create-rollback`), worktree removal outcomes (`record_removal_outcome` now covers both creation-rollback arms), and the audit's record reap (no new event). Does not touch child processes, binaries or re-exec modes, shutdown, or logs beyond the existing `tracing::warn!` texts. The `create-rollback` mode and the reason tokens `internal` (failed rollback) and none (successful rollback) are documented by Phase 9 in `events.md`. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md new file mode 100644 index 000000000..479e2f32a --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md @@ -0,0 +1,121 @@ +# Phase 4: Update install guard + +- Goal: A remote update install keeps Files edits held across a reload, a lost ended event and a source registered mid-install, the installing dialog always has a way out, and the desktop event names are tied by a test, each with a failing-first test. +- Round: 1 +- Depends on: none +- Owns: `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/files/unsavedFileEdits.test.ts`, `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`, `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` and its test (only if Task 4.4 shows the action is unreachable), new `scripts/desktop-event-names.test.ts` +- Must not touch: `apps/web/src/tauriDesktopBridge.ts` and `apps/desktop/src-tauri/**` (read only), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ui/**` +- Skills: `tdd` (every task is a behavior change with a failing test first), `vercel-react-best-practices` (the dialog and host are React), `better-interface` (new button and text need the UI.md review) +- Read first: `AGENTS.md` (completion rules, sensitive data); `UI.md` (a dialog needs a way out, disabled states explain themselves); `docs/architecture/overview.md` section on the desktop close protocol and `desktop:update-install-ended` (current contract); `docs/plans/2026-11-10-desktop-save-and-close-design.md` (R62 and the event table) +- Checkpoint: `cd apps/web && vp test run --project unit src/components/files/unsavedFileEdits.test.ts src/components/files/UnsavedChangesCloseDialog.test.tsx` and `vp test run scripts/desktop-event-names.test.ts` from the repository root both exit 0, then `vp check` and `vp run typecheck` exit 0 + +Facts confirmed in source (bridge contract, `packages/contracts/src/ipc.ts`): `getUpdateState: () => Promise`, `onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void`, `onUpdateInstallEnded?: (listener: () => void) => () => void`. `DesktopUpdateState.requestedBy` is a requester or `null`/absent; `phase` is one of `idle`, `checking`, `available`, `protecting`, `installing`, `failed`. In Rust, `requestedBy` is cleared when the remote flow returns on every path, and `phase` becomes `protecting` then `installing` only after the window allowed the install. "A remote install is in progress" therefore means `requestedBy` is set and `phase` is `protecting` or `installing`. + +## Tasks + +### Task 4.1: Hold a source registered during the install + +- Satisfies: R37. Given an install the window allowed, When a registry registers a source after the block began, Then its saving is held until the install ends. +- Depends on: none +- Files: modify `apps/web/src/components/files/unsavedFileEdits.ts`; test `apps/web/src/components/files/unsavedFileEdits.test.ts` +- Interfaces: consumes `UnsavedSource.holdSavingForLeave(): () => void`, `registerUnsavedSource(source: UnsavedSource): () => void`; produces the module-level `updateInstall` as `{ readonly holds: (() => void)[]; readonly release: () => void } | null`, where `release` releases every hold in `holds`, and `registerUnsavedSource` pushes `source.holdSavingForLeave()` onto `holds` while `updateInstall !== null`. No exported signature changes. +- Skills: tdd (red-green cycle) +- Read first: `apps/web/src/components/files/unsavedFileEdits.ts` (`blockForUpdateInstall`, `endUpdateInstall`, `registerUnsavedSource`); `apps/web/src/components/files/fileEditingSessionRegistry.ts` (`holdSavingForLeave` is idempotent) +- Tier: standard +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/components/files/unsavedFileEdits.test.ts` exits 0 and the new test is listed as passed + +Steps + +1. [ ] Write the failing test `a source registered during an update install is held until the install ends` in `unsavedFileEdits.test.ts`, inside the "desktop close requests" describe. Use `fakeSource` for one source, start an `update` close request, answer allow, then register a second `fakeSource` and assert its `holdSavingForLeave` was called once; fire the ended event and assert the returned release ran once. +2. [ ] Run `cd apps/web && vp test run --project unit src/components/files/unsavedFileEdits.test.ts -t "registered during an update install"`. Expected: FAIL with `holdSavingForLeave` called 0 times. +3. [ ] In `unsavedFileEdits.ts` change `blockForUpdateInstall` to keep its holds in a mutable array on `updateInstall`, and make `registerUnsavedSource` push a hold onto it while `updateInstall !== null`. +4. [ ] Run the same command. Expected: PASS. +5. [ ] Prove the test can fail: remove the push in `registerUnsavedSource`, run the command. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] Run `vp check apps/web/src/components/files/unsavedFileEdits.ts apps/web/src/components/files/unsavedFileEdits.test.ts`. Expected: no errors. + +### Task 4.2: Restore the block when the window loads during an install + +- Satisfies: R35. Given a window that reloads while the desktop reports `requestedBy` set and `phase` `protecting` or `installing`, Then edits are blocked again and the installing dialog shows. A desktop without `getUpdateState` counts as "not installing". A query that fails (rejects, or gives no answer within 5 seconds) is retried and never releases a block. +- Depends on: 4.1 +- Files: modify `apps/web/src/components/files/unsavedFileEdits.ts`; test `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` +- Interfaces: consumes `DesktopBridge["getUpdateState"]` and `["onUpdateState"]`; changes `respondToDesktopCloseRequests(bridge: Pick): () => void` (the last two optional at runtime); produces the non-exported `isRemoteInstallRunning(state: DesktopUpdateState | null | undefined): boolean` and `askInstallState(bridge, onAnswer: (running: boolean) => void): () => void`, which returns its cancel function. Used again by task 4.3. +- Retry rule: `askInstallState` calls `getUpdateState()` with a 5 second timeout. A rejection or timeout is not an answer: it logs once with `console.warn` and asks again every 2 seconds, without limit. The loop stops on the first confirmed answer, on any state pushed by `onUpdateState` (a push is a confirmed answer), and on cleanup. A confirmed answer that is not `isRemoteInstallRunning` releases a block; a failure never does. +- Before the first answer: a fresh load or reload holds nothing. The window cannot know, a reload has lost the block, and holding every save on a guess would stall editing on every start. If the answer is "running", `blockForUpdateInstall()` holds all sources, including ones registered meanwhile (4.1). This differs from a known block (the module already has `updateInstall`): there a failed query changes nothing, so the block stays. +- Skills: tdd (red-green cycle) +- Read first: `apps/web/src/components/files/unsavedFileEdits.ts` (`respondToDesktopCloseRequests`); `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` (describe "a remote update install the window allowed") +- Tier: most capable +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx` exits 0 with the new tests passed + +Steps + +1. [ ] In the describe "a remote update install the window allowed", extend the fake bridge so `getUpdateState` and `onUpdateState` can be set per test; use fake timers for the retry tests. Write the failing test `a window that loads during an install blocks saving and shows the installing dialog`: state `{ requestedBy: { label: "Test User", detail: null }, phase: "installing" }`, an unsaved session, mount the host, assert the dialog text contains "Installing an update" and that no write happens after the autosave delay (`afterTheSaveDelay`). +2. [ ] Write the failing test `a reload during an install whose first query rejects keeps asking and then ends the block`: first call rejects; assert nothing is blocked yet and the query is asked again after 2 seconds; the second call answers running, so the dialog shows and saving is held; a third (via the next recheck or a pushed state) answers `{ requestedBy: null, phase: "idle" }` and the block ends and saving resumes. Add `a failed query never releases a known block` (block started through the close protocol, `getUpdateState` rejects or hangs past 5 seconds: the dialog stays and the query repeats) and `a pushed update state ends the wait`. +3. [ ] Write `a desktop without getUpdateState does not block` (member absent: no dialog, `session.flush()` saves, no timer left running). +4. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "loads during an install|first query rejects|known block|pushed update state ends|without getUpdateState"`. Expected: FAIL (no dialog, no retry); the absent-member test passes already. +5. [ ] Implement `isRemoteInstallRunning` and `askInstallState` and call it from `respondToDesktopCloseRequests` on subscribe; cancel it in the returned cleanup. +6. [ ] Run the same command. Expected: PASS. +7. [ ] Prove the tests can fail: make a rejection count as "not running", run. Expected: the rejects and known-block tests FAIL. Restore and run. Expected: PASS. +8. [ ] Run `vp check` on the two files. Expected: no errors. + +### Task 4.3: Give the installing dialog a way out + +- Satisfies: R36. Given the ended event never arrives, When the update state shows no remote install in progress, Then the dialog closes and editing resumes. Given the dialog is up, Then a button asks the desktop for the state again. +- Depends on: 4.2 +- Files: modify `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`; test `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` +- Interfaces: consumes `onUpdateState(listener)` and `getUpdateState()` from the bridge, `isRemoteInstallRunning` from 4.2; produces `export function recheckUpdateInstall(): Promise` in `unsavedFileEdits.ts` (asks through `askInstallState` from 4.2 and, only on a confirmed answer with `!isRemoteInstallRunning(state)`, calls the existing `endUpdateInstall`; a rejection or timeout keeps the dialog and the block, logs, and the 4.2 retry rule applies). The dialog button label is "Check again". Subscription: `respondToDesktopCloseRequests` also subscribes to `onUpdateState` and ends the install when a pushed state is not a running remote install, unsubscribed in its returned cleanup. No polling. +- Skills: tdd (red-green cycle); better-interface (new button, new text, UI.md review); vercel-react-best-practices (dialog component change) +- Read first: `UI.md` (Buttons and Actions, Errors, Progress and Feedback); `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx` (`UpdateInstallingDialog`, `UnsavedChangesCloseHost`) +- Tier: standard +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx src/components/files/unsavedFileEdits.test.ts` exits 0 + +Steps + +1. [ ] Write the failing test `a pushed update state with no remote install closes the installing dialog` (fake bridge captures the `onUpdateState` listener; after allow, push `{ requestedBy: null, phase: "idle" }`; assert the dialog is gone, saving resumes, and the "The update did not install" toast was added). +2. [ ] Write the failing test `Check again asks the desktop for the state and closes the dialog when no install runs`: dialog shows a button named "Check again"; `getUpdateState` resolves a not-running state; click; assert the dialog closes. Also `Check again with an install still running keeps the dialog`, and `a rejecting state query keeps the dialog, the block and the button, and the 4.2 retry keeps asking`. +3. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "pushed update state|Check again|rejecting state"`. Expected: FAIL (no such button, dialog stays). +4. [ ] Implement the `onUpdateState` subscription and `recheckUpdateInstall`, and add the "Check again" `Button variant="outline"` in a footer of `UpdateInstallingDialog` with a disabled state while the query runs. The description keeps its current text and gains one sentence: "If it seems stuck, check again." UI.md review: confirm the dialog now has a visible exit, the button is not the primary action, and Escape still does nothing while an install may restart the app. +5. [ ] Run the same command. Expected: PASS. +6. [ ] Prove the tests can fail: remove the `endUpdateInstall` call in `recheckUpdateInstall`, run. Expected: FAIL. Restore and run. Expected: PASS. +7. [ ] Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the React and UI.md review outcome in the final report. + +### Task 4.4: Decide whether the dialog hides the Manage devices toast + +- Satisfies: R39 (decide by test). Given the installing dialog open over the remote-update toast, Then either the toast action is reachable (the test stays as proof) or the action is made reachable from the dialog. +- Depends on: 4.3 +- Files: modify `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`; only on a defect, modify `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx` and `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; test `apps/web/src/components/RemoteUpdateRequestNotifier.test.tsx` on a defect +- Interfaces: consumes `RemoteUpdateRequestNotifier({ onManageDevices })` and the toast manager from `~/components/ui/toast`; on a defect produces an optional `onManageDevices?: () => void` prop on `UnsavedChangesCloseHost`, shown as a secondary button "Manage devices" in the installing dialog +- Skills: mstack-diagnose (the result of a DOM probe picks the branch); better-interface (any new action needs the UI.md review) +- Read first: `apps/web/src/components/ui/toast.tsx` (`Toast.Portal`, `Toast.Viewport`); `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; `apps/web/src/AppRoot.tsx` (where the notifier and host mount) +- Tier: most capable +- TDD: exempt (the test is the deliverable; code changes only on a defect, and then red-green) +- Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast"` exits 0 and the outcome line is recorded in the final report + +Steps + +1. [ ] Write the DOM test `the Manage devices toast action is reachable while the installing dialog is open`: mount the real toast viewport (not the mocked manager), add a toast with an action as the notifier does, open the installing dialog, then assert whether the action element has an `[inert]` or `[aria-hidden="true"]` ancestor and whether `document.elementFromPoint` or a keyboard Tab can reach it. +2. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast"`. Expected: either PASS (no defect) or FAIL on the inert assertion (defect). +3. [ ] Result "no defect" (PASS): keep the test as proof, add no code, and write "R39: no defect, toast action reachable over the dialog" in the final report. +4. [ ] Result "defect" (FAIL): add the "Manage devices" secondary button to `UpdateInstallingDialog` through an `onManageDevices` prop passed from where `AppRoot.tsx` mounts the host; add a test in the dialog test file that the click calls it; run `vp test run --project unit` on both test files. Expected: PASS. Run the UI.md review on the new action. +5. [ ] Run `vp check` on changed files. Expected: no errors. + +### Task 4.5: Fail when a desktop event name exists on one side only + +- Satisfies: R38. Given a `desktop:` event name in the web bridge with no matching Rust literal, or the reverse, Then the test fails. +- Depends on: none +- Files: create `scripts/desktop-event-names.test.ts` +- Interfaces: consumes `apps/web/src/tauriDesktopBridge.ts` string literals matching `"desktop:[a-z-]+"` and the same pattern in `apps/desktop/src-tauri/src/*.rs`; produces the test `every desktop event name exists on both sides of the bridge`. Follow the file style of `scripts/module-case-collisions.test.ts` (`node:fs`, `NodePath`, `import.meta.dirname`, `vite-plus/test`, the `@effect-diagnostics nodeBuiltinImport:off` comment). +- Skills: tdd (red-green cycle) +- Read first: `scripts/module-case-collisions.test.ts` (naming and imports); `apps/web/src/tauriDesktopBridge.ts` (the `*_EVENT` constants and `desktop:ssh-password-prompt`); `apps/desktop/src-tauri/src/window.rs`, `updates.rs`, `remote_update_delegate.rs` (the Rust constants) +- Tier: cheap +- TDD: red-green +- Done when: `vp test run scripts/desktop-event-names.test.ts` exits 0 + +Steps + +1. [ ] Write `every desktop event name exists on both sides of the bridge`: collect the literal sets, assert both are non-empty, and assert set equality, with a failure message that lists the names missing on each side. Allow an explicit `WEB_ONLY` or `RUST_ONLY` list, empty unless a name is emitted by a plugin; justify any entry in a comment. +2. [ ] Run `vp test run scripts/desktop-event-names.test.ts`. Expected: PASS on current code. If it fails, the failure message names a real mismatch: report it, do not edit the bridge or Rust files (not owned by this phase). +3. [ ] Prove the test can fail: temporarily change one string in a scratch copy of the collected set inside the test (for example rename `desktop:update-install-ended` in the in-memory text), run. Expected: FAIL naming the event. Remove the break and run again. Expected: PASS. +4. [ ] Run `vp check scripts/desktop-event-names.test.ts`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md new file mode 100644 index 000000000..4c7dde2cc --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md @@ -0,0 +1,93 @@ +# Phase 5: Reasoning row scroll and Host dashboard re-render + +- Goal: Expanding or collapsing a reasoning row that the reader brought into view keeps its header within 2 px (R12, diagnosed first, fixed at the first wrong decision), and the Host dashboard values view no longer re-renders on the clock tick alone (R18), each with its tests. +- Round: 1 +- Depends on: none +- Owns: `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/ChatView.test.tsx`, `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx`, `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (only if the diagnosis lands there), `apps/web/src/components/chat/MessagesTimeline.tsx` (only the `onReasoningRowToggle` call in `ReasoningWorkEntryRow`, only if the diagnosis needs the row id), `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` +- Must not touch: `apps/desktop/e2e/**` (Phase 7 owns the harness; the packaged spec is run, never edited), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ChatMarkdown.tsx`, `apps/web/src/components/hostDashboard/hostDashboard.logic.ts` +- Skills: `vercel-react-best-practices` (required review for every React change under apps/web); `tdd` (red-green cycle for each behavior change); `mstack-diagnose` (Task 5.1 is a diagnosis, not a fix) +- Read first: `AGENTS.md` (completion rules, profiler coverage line, sensitive data); `UI.md` (the toggle must not move the reader's place); `docs/architecture/overview.md` section on the web client (state owner of timeline scroll); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (R12, R18 and D14 are binding); `apps/desktop/e2e/specs/chat-reasoning-rows.e2e.ts` (the packaged invariant: `expandThoughtAwayFromEnd` and `expandStreamingThoughtAtEnd`) +- Checkpoint: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0, then `vp check ` and `vp run typecheck` exit 0. Later proof, owned by Phase 8 (Tasks 8.1 and 8.3) and not by this phase: the packaged `chat-reasoning-rows` spec in the Linux lab at a 1000x720 viewport (a failure there reproduces the bug without WebView2; it does not by itself name the cause), and in the Windows VM at the viewport where it failed. + +## Background (read before Task 5.1) + +Three code paths can scroll the list to its end after a reasoning row toggles. All are in `ChatView.tsx` unless noted. + +- H1, the toggle's end reveal: `onReasoningRowToggle`. It runs when `rowToggleNeedsEndReveal({ mode, scrollDeltaToRevealEnd })` is true (mode `anchoring-new-turn` and `scrollDeltaToRevealEnd <= 1` before the toggle) and `liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current`. Two animation frames later it calls `list.scrollToOffset({ offset: scroll + scrollDeltaToRevealEnd, animated: false })`. Two frames is 16 to 50 ms, the window the issue measured. +- H2, the grown-turn follow: `onTimelineRowSizeChanged` then `followGrownTurn`. In mode `anchoring-new-turn` a row that grows starts a two-frame wait, then `followTimelineLiveEnd` runs when `scrollDeltaToRevealEnd - (growth - held) <= 1`, which is the same "the end was in view before" condition. +- H3, the list's end maintenance: `MessagesTimeline` passes `maintainScrollAtEnd={resolveTimelineMaintainScrollAtEnd({ anchored, followsEnd })}`; with `followsEnd` true the config has `on.itemLayout: true`, so LegendList scrolls to its end by itself when it judges the list at its end. + +What makes all three possible: the packaged spec scrolls with `scrollIntoView({ block: "center" })`, which is not a reader gesture. `onIsAtEndChange(false)` returns early when `liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current`, so after that scroll the mode stays `anchoring-new-turn` and `timelineFollowsEnd` stays true. + +Paths in this plan: the repository keeps `timelineScrollAnchoring.ts` and `MessagesTimeline.logic.ts` under `apps/web/src/components/chat/`, not directly under `components/`. + +## Tasks + +### Task 5.1: Diagnose which path scrolls to the end after a programmatic scroll and a toggle + +- Satisfies: R12 (diagnose-first step 2). Scenario pinned: Given the reader scrolled a reasoning row to the center without a wheel or touch gesture, When the row is toggled, Then no scroll to the end is made within three animation frames. +- Depends on: none +- Files: modify `apps/web/src/components/ChatView.test.tsx` +- Interfaces: consumes `ChatView` props captured by `capturedProps>("messagesTimeline")` with the keys `onReasoningRowToggle: () => void`, `onRowSizeChanged: (info: { index: number; previous: number; size: number }) => void`, `onIsAtEndChange: (isAtEnd: boolean) => void`, `onAnchorReady: (id: MessageId, index: number) => void` and `followsEnd: boolean`; consumes the `h.timelineList` mock with `getState`, `getScrollableNode`, `scrollToOffset`, `scrollToIndex`, `scrollToEnd` as in the test "a pointer press on $pressed before a toggle at an anchored turn's end". Produces a `describe("R12 scroll path after a programmatic scroll")` with three tests, one per hypothesis, each exercising its path alone, and a verdict comment naming the confirmed path or paths. Task 5.2 relies on the verdict and on the shared helper `anchoredTurnAfterProgrammaticScroll()` that this task adds in the test file. +- Skills: mstack-diagnose (decide by test; no fix before the verdict) +- Read first: `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end" (the harness to copy); `apps/web/src/components/ChatView.tsx` `onIsAtEndChange`, `onReasoningRowToggle`, `followGrownTurn`, `onTimelineRowSizeChanged`; `apps/web/src/components/chat/timelineScrollAnchoring.ts` `rowToggleNeedsEndReveal` +- Tier: most capable +- TDD: red-green +- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` exits 0 and the verdict comment above the `describe` names which of H1, H2, H3 issued a scroll. + +Steps + +1. [ ] Read the baseline lab result from the plan log (the coordinator runs the packaged `chat-reasoning-rows` spec in the Linux lab on the base commit before round 1, plan decision P3). Use it only to choose which metrics to reproduce: whether `expandThoughtAwayFromEnd` failed there, the header movement it reported, and the viewport and `scrollTop` it recorded. A failure at 1000x720 is a platform-independent reproduction, not proof of the cause. A pass there means the unit metrics below must stay the issue's WebView2 numbers, and is no evidence for or against any hypothesis. If the log has no baseline result yet, continue with the issue's numbers and say so in the verdict comment. +2. [ ] Add the helper `anchoredTurnAfterProgrammaticScroll()` to the test file. It copies the setup of the existing #173 test (seed the thread, render `ChatView`, send, call `onAnchorReady`) with the spec's metrics: `scroll: 0`, `scrollLength: 720`, an anchor row at index 0, the turn's last row ending inside the viewport so `scrollDeltaToRevealEnd` is at most 1 before the toggle, and no `pointerdown` on the list or the toggle. It then calls `onIsAtEndChange(false)` to stand for the programmatic scroll. It returns the timeline props, the `scrollToOffset`, `scrollToIndex` and `scrollToEnd` mocks, and a `growRow(height: number)` function that raises the last row's height by 1014 px, the distance the issue measured. +3. [ ] Write test `H1: the toggle's end reveal makes no scroll after a programmatic scroll` as `it.fails`. Prediction if H1 is the culprit: calling `onReasoningRowToggle()`, then `growRow(1014)`, then advancing three animation frames gives exactly one `scrollToOffset` call with an offset at or above the old end, so the assertion `scrollToOffset` not called fails, which `it.fails` accepts. +4. [ ] Write test `H2: the grown-turn follow makes no scroll after a programmatic scroll` as `it.fails`. It never calls `onReasoningRowToggle`. It calls `onRowSizeChanged({ index, previous, size: previous + 1014 })` after `growRow(1014)` and advances three frames. Prediction if H2 is the culprit: one `scrollToOffset`, `scrollToIndex` or `scrollToEnd` call. +5. [ ] Write test `H3: the list's end maintenance stays off after a programmatic scroll and a toggle` as `it.fails`. It calls `onReasoningRowToggle()` and reads `followsEnd` from the re-captured `messagesTimeline` props. Prediction if H3 is the culprit: `followsEnd` is still `true`, so LegendList's `maintainScrollAtEnd` stays on while the reader's row is away from the end. Assert `followsEnd` is `false`. +6. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` from `apps/web`. Run it once with each `it.fails` temporarily changed to `it`. Expected: FAIL for the tests of the confirmed hypotheses, that is, they FAIL with the assertion messages above, and the others PASS. A test that passes as `it` is a path that does not scroll; keep it as a plain `it`. +7. [ ] Write the verdict comment above the `describe`: which of H1, H2, H3 failed, and the first wrong decision for each: H1 is the reveal decision in `onReasoningRowToggle` (it cannot tell the toggled row from the newest row), H2 is the follow decision in `followGrownTurn` (same blind spot), H3 is `timelineFollowsEnd` staying true after a programmatic scroll away from the end. If none fails (no path scrolls with these metrics), say so, keep the three tests as plain `it`, and stop: report to the coordinator that the unit metrics do not reproduce the packaged failure, so Phase 8's Linux-lab run at 1000x720 (Task 8.1) is the next step and Task 5.2 makes no code change. +8. [ ] Restore the `it.fails` marks on the confirmed paths and run the command of step 6. Expected: PASS. +9. [ ] Run `vp check src/components/ChatView.test.tsx` from `apps/web`. Expected: no errors. + +### Task 5.2: Fix the first wrong decision the diagnosis names + +- Satisfies: R12. Scenarios pinned: Given a programmatic scroll to a reasoning row and a toggle, Then no scroll to the end is made within three animation frames; Given the newest reasoning row at the end of an anchored turn, Then the #173 reveal still happens. +- Depends on: 5.1 +- Files: modify `apps/web/src/components/ChatView.tsx`; modify `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx` (H1 or H2); modify `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (H3 only); modify `apps/web/src/components/chat/MessagesTimeline.tsx` (H1 only); test `apps/web/src/components/ChatView.test.tsx` +- Interfaces: consumes the verdict comment and `anchoredTurnAfterProgrammaticScroll()` from Task 5.1. Produces, per confirmed hypothesis, exactly one of: (H1) `rowToggleNeedsEndReveal(input: { mode: TimelineScrollMode; scrollDeltaToRevealEnd: number | null; toggledRowIsNewest: boolean }): boolean` returning false when `toggledRowIsNewest` is false, with `onReasoningRowToggle(rowId: string): void` in `ChatView.tsx` and `MessagesTimeline.tsx` passing `reasoning.id`, and `toggledRowIsNewest` computed from `list.getState().data` as "no reasoning or work row follows the toggled row before the working row"; (H2) the same predicate applied in `onTimelineRowSizeChanged`, through a `reasoningToggleRowIdRef` set by `onReasoningRowToggle` for one frame pair so a size change caused by a non-newest toggle does not start `followGrownTurn`; (H3) when `onReasoningRowToggle` decides not to reveal, it calls `setTimelineFollowsEnd(false)`, and `onIsAtEndChange(true)` restores it, so `resolveTimelineMaintainScrollAtEnd({ anchored, followsEnd: false })` returns `false`. If several hypotheses were confirmed, apply the fix of each in the order H1, H2, H3, one test each. +- Skills: tdd (red-green for the changed decision); vercel-react-best-practices (review of changed callbacks and refs) +- Read first: the verdict comment from Task 5.1; `apps/web/src/components/chat/MessagesTimeline.tsx` `ReasoningWorkEntryRow` (where `onReasoningRowToggle()` is called); `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end" (must still pass unchanged) +- Tier: standard +- TDD: red-green +- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts` exits 0, including the unchanged #173 test. + +Steps + +1. [ ] Flip the confirmed tests of Task 5.1 from `it.fails` to `it`. They are the failing tests of this task. +2. [ ] Add a second test beside them, `the newest reasoning row at the end of an anchored turn still reveals the end after a toggle`, built on the #173 test's setup (a toggle of the newest row with a pointer press on its button). Assert exactly one `scrollToOffset` call with an offset above 2000, as the #173 test does. +3. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: FAIL on the flipped tests with the assertion messages from Task 5.1 step 6; the new reveal test PASSES. +4. [ ] Implement the fix named for the confirmed hypothesis in Interfaces, at the first wrong decision only. For H1 add the `toggledRowIsNewest` cases to `timelineScrollAnchoring.test.tsx` next to the existing `rowToggleNeedsEndReveal` cases: `mode: "anchoring-new-turn"`, `scrollDeltaToRevealEnd: 0`, `toggledRowIsNewest: false` gives false; `true` gives true. +5. [ ] Run the Done-when command. Expected: PASS, including "a pointer press on a Thinking row's toggle before a toggle at an anchored turn's end" for both rows of its table. +6. [ ] Prove the test can fail: revert the fix temporarily (make the new predicate or guard always true), run the Done-when command. Expected: FAIL on the flipped R12 test. Restore the fix and run it again. Expected: PASS. +7. [ ] Review the changed callbacks and refs against `vercel-react-best-practices` (stable callbacks, no state in a render path, no effect added). Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. +8. [ ] State the profiler coverage line for the final report: none of child processes, RPC methods, worktree removal, binaries, shutdown, events or findings is touched. + +### Task 5.3: Stop the Host dashboard values view re-rendering on the clock tick + +- Satisfies: R18. Scenario pinned: Given an open dashboard whose reading has not changed, When the 1 s tick fires three times, Then the values view and its charts do not render again. +- Depends on: none +- Files: modify `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx`; test `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` +- Interfaces: consumes `HostDashboardValuesView({ values: HostDashboardValues; view: HostMetricsView | null; timestampFormat })` and `HostMetricBars`. `presentHostDashboard` builds a fresh `values` object and fresh `volumes`, `hiddenVolumes` and `statistics` arrays each render, so a reference check cannot work. Produces `const HostDashboardValuesView = memo(Inner, valuesViewPropsEqual)` where `valuesViewPropsEqual(previous, next): boolean` is true when `view` and `timestampFormat` are identical by reference and `values` is structurally equal (the same strings and rows). Search `apps/web/src` for an existing deep-equal helper first and use it; if none exists, write a local structural comparison in the sheet file. The `since` text carries the uptime, so it changes on its own schedule and correctly lets the view render then. +- Skills: tdd (red-green for the render count); vercel-react-best-practices (memo comparator review) +- Read first: `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` `useVisibleSecondTick` and `HostDashboardValuesView`; `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` test "dims the values after 3 seconds without an item" (fake timer setup to copy); `apps/web/src/components/hostDashboard/HostMetricBars.tsx` (the child whose renders are counted) +- Tier: standard +- TDD: red-green +- Done when: from `apps/web`, `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0. + +Steps + +1. [ ] Write the failing test `does not render the values view again when only the clock tick changed` in `HostDashboardSheet.test.tsx`. Use the fake timers of the "dims the values" test. Mock `./HostMetricBars` with a wrapper around the real component that increments a counter on each render. Render with `h.data = view()`, record the count, then `await act(async () => vi.advanceTimersByTime(3_000))` with the same data. Assert the counter is unchanged. Also assert the text `Last updated 3 s ago` still shows, so the sheet itself did re-render and only the values view was skipped. +2. [ ] Run `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx -t "clock tick"` from `apps/web`. Expected: FAIL with the counter higher than the recorded value. +3. [ ] Implement `valuesViewPropsEqual` and wrap `HostDashboardValuesView` in `memo` with it, in `HostDashboardSheet.tsx`. +4. [ ] Add a second assertion test `renders the values view again when a reading changes`: replace `h.data` with a view whose CPU differs and assert the counter rises and the new percent shows. Run the Done-when command. Expected: PASS. +5. [ ] Prove the test can fail: make `valuesViewPropsEqual` return false, run the Done-when command. Expected: FAIL on the clock tick test. Restore it and run again. Expected: PASS. +6. [ ] Review against `vercel-react-best-practices` (comparator cost is small next to a render; no inline object props added). Run `vp check apps/web/src/components/hostDashboard/HostDashboardSheet.tsx apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` and `vp run typecheck`. Expected: no errors. +7. [ ] Log in the final report that no user-visible string, state or layout changed, so the `UI.md` review has nothing new to review. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md new file mode 100644 index 000000000..220d9cb4a --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md @@ -0,0 +1,101 @@ +# Phase 6: Popup occlusion, model picker reopen and read-only Markdown images + +- Goal: A macOS native preview hides only under an open popup that overlaps it, the composer model picker reopens empty, and the read-only Markdown view in Files loads relative images from the workspace, each with a failing-first test. +- Round: 1 +- Depends on: none +- Owns: `apps/web/src/browser/browserSurfaceStore.ts`, `apps/web/src/browser/browserSurfaceSync.ts` and their tests (`browserSurfaceStore.test.ts`, `browserSurfaceSync.test.ts`), `apps/web/src/components/ui/useNativePreviewOcclusion.ts`, new `apps/web/src/components/ui/usePopupOcclusion.ts`, `apps/web/src/components/ui/popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx`, new `apps/web/src/components/ui/popup-occlusion.dom.test.tsx`, `apps/web/src/components/chat/ProviderModelPicker.tsx` and `ProviderModelPicker.test.tsx`, `apps/web/src/components/ChatMarkdown.tsx` and its tests (`ChatMarkdown.test.tsx`), the `RenderedMarkdownSurface` function in `apps/web/src/components/files/FilePreviewPanel.tsx` and `FilePreviewPanel.test.tsx` +- Must not touch: `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/hostDashboard/**`, `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/ui/tooltip.tsx` +- Skills: `tdd` (every task changes behavior test-first), `vercel-react-best-practices` (React changes under apps/web), `better-interface` (picker state and image error text are user-visible) +- Read first: `AGENTS.md` (completion rules); `UI.md` (preserve user work, honest empty and error states); `docs/architecture/overview.md` section on the native browser preview and Files rendered view (current behavior) +- Checkpoint: `cd apps/web && vp test run --project unit src/browser src/components/chat/ProviderModelPicker.test.tsx src/components/ChatMarkdown.test.tsx src/components/files/FilePreviewPanel.test.tsx` exits 0, the `*.dom.test.tsx` files named below exit 0, then `vp check` and `vp run typecheck` exit 0 + +Facts confirmed in source: the store files live in `apps/web/src/browser/` (not `apps/web/src/`). `ProviderModelPicker.tsx` lives in `apps/web/src/components/chat/`. `occlusionOwners` is a `ReadonlySet` in `BrowserSurfaceStoreState`; `browserSurfaceSync.ts` computes `occluded = state.occlusionOwners.size > 0`. `PopoverPopup` renders `PopoverPrimitive.Portal keepMounted`; `MenuPopup`, `SelectPopup`, `ComboboxPopup` and `AutocompletePopup` each render Portal, Positioner, Popup. `ModelPickerContent` keeps the search text in `useState("")` and stays mounted inside the keep-mounted popover. Existing DOM occlusion test to copy: `apps/web/src/components/ui/dialog.occlusion.dom.test.tsx` (it mocks `~/env` and the platform). Decision D11: tooltips take no lease and `tooltip.tsx` is not touched. + +## Tasks + +### Task 6.1: Occlusion leases that carry a rectangle + +- Satisfies: R40, store half. Given a lease with a rectangle, Then a native surface hides only when that rectangle intersects the surface's rectangle; a lease without a rectangle hides every surface as today. +- Depends on: none +- Files: modify `apps/web/src/browser/browserSurfaceStore.ts`, `apps/web/src/browser/browserSurfaceSync.ts`; test `apps/web/src/browser/browserSurfaceStore.test.ts`, `apps/web/src/browser/browserSurfaceSync.test.ts` +- Interfaces: consumes `BrowserSurfaceRect`; produces `occlusionOwners: ReadonlyMap` (null covers everything; `.size` keeps working for existing tests), `acquireBrowserSurfaceOcclusion(rect?: BrowserSurfaceRect): BrowserSurfaceOcclusionLease` where `BrowserSurfaceOcclusionLease` becomes `{ readonly update: (rect: BrowserSurfaceRect | null) => void; readonly release: () => void }`, and an exported pure function `isBrowserSurfaceOccluded(rect: BrowserSurfaceRect, owners: ReadonlyMap): boolean` (true when any owner is null or its rectangle overlaps `rect` by more than 0 px on both axes). +- Skills: tdd (red-green cycle) +- Read first: `apps/web/src/browser/browserSurfaceStore.ts` (`acquireBrowserSurfaceOcclusion`); `apps/web/src/browser/browserSurfaceSync.ts` (`push`); `apps/web/src/browser/browserSurfaceSync.test.ts` ("keeps a live surface hidden until every occlusion lease releases") +- Tier: standard +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/browser` exits 0 + +Steps + +1. [ ] Write failing tests in `browserSurfaceStore.test.ts`: `isBrowserSurfaceOccluded` is true for a null owner, true for an overlapping rectangle, false for a disjoint one and false for a rectangle that only touches an edge; `update` replaces a lease's rectangle and `release` after `update` still removes it. +2. [ ] Write the failing sync test in `browserSurfaceSync.test.ts`: with a surface at `{ x: 0, y: 0, width: 400, height: 300 }`, a lease at `{ x: 500, y: 0, width: 100, height: 100 }` leaves `setBounds` visible; moving it to `{ x: 100, y: 100, width: 50, height: 50 }` with `update` hides it; releasing shows it again. +3. [ ] Run `cd apps/web && vp test run --project unit src/browser`. Expected: FAIL with `isBrowserSurfaceOccluded is not a function` and a surface hidden by a disjoint lease. +4. [ ] Implement the `Map`, the two functions above and the `push` change (`visible: presentation.visible && !isBrowserSurfaceOccluded(presentation.rect, state.occlusionOwners)`). Keep `acquireBrowserSurfaceOcclusion()` with no argument meaning a null rectangle so `useNativePreviewOcclusion` and the dialogs behave as before. +5. [ ] Run `cd apps/web && vp test run --project unit src/browser src/components/ui/dialog.occlusion.dom.test.tsx`. Expected: PASS. +6. [ ] Prove a test can fail: make `isBrowserSurfaceOccluded` return true for every owner, run. Expected: the disjoint-rectangle tests FAIL. Restore and run. Expected: PASS. +7. [ ] Run `vp check` on the changed files. Expected: no errors. + +### Task 6.2: Popups hold a rectangle lease only while open + +- Satisfies: R40, popup half. Given a macOS desktop host, an open Popover, Menu, Select, Combobox or Autocomplete popup, Then it leases its own rectangle; closing releases it; a popup kept mounted while closed holds no lease; tooltips hold none (D11); dialogs and sheets keep their full lease. +- Depends on: 6.1 +- Files: create `apps/web/src/components/ui/usePopupOcclusion.ts`, `apps/web/src/components/ui/popup-occlusion.dom.test.tsx`; modify `apps/web/src/components/ui/popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx`, `apps/web/src/components/ui/useNativePreviewOcclusion.ts` (replace the "Not for menus and popovers" comment with a pointer to the new hook) +- Interfaces: consumes `acquireBrowserSurfaceOcclusion(rect?)` from 6.1, `isDesktopHost` from `~/env`, `isMacPlatform(navigator.platform)` from `~/lib/utils`; produces `usePopupOcclusion(open: boolean, positionerRef: React.RefObject): void` which, while `open` and on macOS desktop, acquires a lease with the positioner's `getBoundingClientRect()` (rounded like `BrowserSurfaceSlot`), calls `lease.update` from a `ResizeObserver` on the positioner and on window `resize`, and releases on close or unmount. The `open` signal for each primitive is read from the Popup element's `data-open` attribute through a small `MutationObserver` inside the hook's caller component `PopupOcclusion({ positionerRef, popupRef })`, rendered inside each Popup; the first step below confirms that signal against the installed Base UI before the rest is built. +- Skills: tdd (red-green cycle); vercel-react-best-practices (effects, observers and cleanup) +- Read first: `apps/web/src/components/ui/useNativePreviewOcclusion.ts`; `apps/web/src/components/ui/dialog.occlusion.dom.test.tsx` (mocks and platform setup to copy); `apps/web/src/components/ui/popover.tsx` (`Portal keepMounted`); `apps/web/src/components/ui/menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx` (Positioner and Popup structure) +- Tier: most capable +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/components/ui/popup-occlusion.dom.test.tsx` exits 0 with every case passed + +Steps + +1. [ ] Write the failing DOM test `popup-occlusion.dom.test.tsx` by copying the setup of `dialog.occlusion.dom.test.tsx`, with cases: (a) an open `PopoverPopup` adds one entry to `useBrowserSurfaceStore.getState().occlusionOwners` whose value is a non-null rectangle; (b) closing it removes the entry although the keep-mounted portal stays in the DOM; (c) a popover that never opened holds no entry; (d) the same for `MenuPopup` and `SelectPopup`, `ComboboxPopup` and `AutocompletePopup` (one case each); (e) a `TooltipPopup` open holds no entry; (f) on a non-macOS platform or `isDesktopHost` false, no entry. Stub `getBoundingClientRect` on the positioner to return a known rectangle and assert the lease value equals it. +2. [ ] Run `cd apps/web && vp test run --project unit src/components/ui/popup-occlusion.dom.test.tsx`. Expected: FAIL with `occlusionOwners.size` 0 where an entry is expected. +3. [ ] Confirm the open signal: in the test, assert that the Popup element carries `data-open` while open and not after close for a keep-mounted popover. If the installed Base UI exposes a different attribute, use that one and write the actual name in the hook's doc comment. +4. [ ] Implement `usePopupOcclusion` and the `PopupOcclusion` child, and render it inside the Popup of the five primitives. Do not edit `tooltip.tsx`. +5. [ ] Run the same command. Expected: PASS. Also run `cd apps/web && vp test run --project unit src/components/ui/popover.test.tsx src/components/ui/combobox.test.tsx src/components/ui/autocomplete.test.tsx src/components/ui/dialog.occlusion.dom.test.tsx`. Expected: PASS (no regression, dialogs still hold a full lease). +6. [ ] Prove the test can fail: make the hook acquire the lease on mount regardless of `open`, run the DOM test. Expected: case (b) and (c) FAIL. Restore and run. Expected: PASS. +7. [ ] Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the `vercel-react-best-practices` review (observer cleanup, no work while closed). State in the final report that a real macOS run of the native preview was not made here. + +### Task 6.3: The model picker reopens with an empty search + +- Satisfies: R46. Given a query typed in the composer model picker, When the picker closes and opens again, Then the search is empty and the current model values show. +- Depends on: none +- Files: modify `apps/web/src/components/chat/ProviderModelPicker.tsx`; test `apps/web/src/components/chat/ProviderModelPicker.test.tsx` +- Interfaces: consumes `ProviderModelPicker` props `open`, `onOpenChange`, `model`, `modelOptionsByInstance`; produces an opening counter in `ProviderModelPicker` (state, incremented in `setIsMenuOpen` when `open` becomes true) used as the `key` of `ModelPickerContent`, so each opening mounts it fresh. No prop changes. +- Skills: tdd (red-green cycle); better-interface (picker state a person sees) +- Read first: `apps/web/src/components/chat/ProviderModelPicker.tsx` (`setIsMenuOpen`, `PopoverPopup`); `apps/web/src/components/chat/ModelPickerContent.tsx` (`searchQuery` state, focus layout effect); `apps/web/src/components/chat/ProviderModelPicker.test.tsx` (`renderPicker`, `click`, "opens and closes an uncontrolled picker around selection") +- Tier: standard +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/components/chat/ProviderModelPicker.test.tsx` exits 0 + +Steps + +1. [ ] Write the failing test `reopening the picker shows an empty search` first, before any change: open, type `gpt` into the search input (`input` inside `[data-model-picker-content]`) with a change event, close with `onOpenChange(false)` (or select Escape), open again, assert the input value is `""` and the list shows every model. Add `reopening after the model prop changed shows the new current model in the list` (re-render with another `model`, close, open, assert the highlighted or selected item is the new one). +2. [ ] Run `cd apps/web && vp test run --project unit src/components/chat/ProviderModelPicker.test.tsx -t "reopening"`. Expected: the first test FAILS with the input value `gpt` (the popover is keep-mounted, so state survives). If it passes, the premise is wrong: record "R46: no defect, state does not survive" and stop; keep the test as proof. +3. [ ] Add the opening counter and key `ModelPickerContent` with it. +4. [ ] Run the same command. Expected: PASS. +5. [ ] Prove the test can fail: remove the `key`, run. Expected: FAIL. Restore and run. Expected: PASS. +6. [ ] Run `vp check` on the two files. Expected: no errors. Record the UI.md check (typing then closing does not need to preserve the query: an empty search is the expected start). + +### Task 6.4: Read-only Markdown loads relative images from the workspace + +- Satisfies: R47. Given the read-only rendered Markdown view in Files (rich editor still loading, file over 128 KiB, or content the editor cannot change safely), When the file has `![x](img/a.png)`, Then the image is read from the workspace relative to the Markdown file through `RichMarkdownImage`; a `../` path that leaves the workspace is refused with the loader's message; chat messages render exactly as before. +- Depends on: none +- Files: modify `apps/web/src/components/ChatMarkdown.tsx`, `apps/web/src/components/files/FilePreviewPanel.tsx` (only `RenderedMarkdownSurface`); test `apps/web/src/components/ChatMarkdown.test.tsx`, `apps/web/src/components/files/FilePreviewPanel.test.tsx` +- Interfaces: consumes `RichMarkdownImage({ workspace: RichMarkdownWorkspace, src: string, alt: string })` and `RichMarkdownWorkspace { environmentId, threadId, cwd, markdownPath }` from `apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx`; produces an optional `markdownPath?: string` prop on `ChatMarkdown` (`ChatMarkdownProps`), carried in `ChatMarkdownRenderState` with the existing `environmentId`, `threadRef` and `cwd`, and an `img` entry in `CHAT_MARKDOWN_COMPONENTS` (`MarkdownImage`, module scope, reads the render state) that renders `RichMarkdownImage` when `markdownPath`, `threadRef` and `cwd` are all present and a plain `` otherwise. `RenderedMarkdownSurface` passes `markdownPath={session.relativePath}`. +- Skills: tdd (red-green cycle); vercel-react-best-practices (module-scope components, stable context) +- Read first: `apps/web/src/components/ChatMarkdown.tsx` (`CHAT_MARKDOWN_COMPONENTS`, `ChatMarkdownRenderState`, the `renderState` memo); `apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx` and `richMarkdownImageSource.ts` (loader and refusal rules); `apps/web/src/components/files/FilePreviewPanel.tsx` (`RenderedMarkdownSurface`); `apps/web/src/components/files/richMarkdown/RichMarkdownImage.test.tsx` (how the loader is mocked) +- Tier: standard +- TDD: red-green +- Done when: `cd apps/web && vp test run --project unit src/components/ChatMarkdown.test.tsx src/components/files/FilePreviewPanel.test.tsx src/components/files/richMarkdown` exits 0 + +Steps + +1. [ ] Write the failing test in `ChatMarkdown.test.tsx`: `renders a relative image through the workspace loader when a markdownPath is given` (mock `RichMarkdownImage` as in its own test; assert it receives `src: "img/a.png"` and a workspace with `markdownPath` `docs/readme.md`) and `renders an image unchanged without a markdownPath` (a plain `img` with the same `src`). +2. [ ] Write the failing test in `FilePreviewPanel.test.tsx`: for each of the three read-only paths (rich editor loading, file over 128 KiB via the too-large notice, unsafe content via the read-only notice) a relative image reaches `RichMarkdownImage`; for `../outside.png` the view shows the text "Only images inside this project are shown." (the loader's refusal). +3. [ ] Run `cd apps/web && vp test run --project unit src/components/ChatMarkdown.test.tsx src/components/files/FilePreviewPanel.test.tsx -t "image"`. Expected: FAIL: the image is a plain `img` with the relative `src`. +4. [ ] Add the prop, the render-state field, `MarkdownImage` and the `img` entry; pass `markdownPath` from `RenderedMarkdownSurface`. Keep `MessagesTimeline`, `PlanSidebar` and `ProposedPlanCard` call sites untouched. +5. [ ] Run the step 3 command and then the Done when command. Expected: PASS, including the existing chat Markdown tests. +6. [ ] Prove a test can fail: make `MarkdownImage` ignore `markdownPath`, run. Expected: the workspace tests FAIL. Restore and run. Expected: PASS. +7. [ ] Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the `vercel-react-best-practices` review (components stay at module scope, no new closure in render). diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md new file mode 100644 index 000000000..f450d71bb --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md @@ -0,0 +1,114 @@ +# Phase 7: Server odds and the packaged e2e harness + +- Goal: Other AppImage mounts are hidden from Host dashboard volumes (R14), the packaged harness checks for a dead webview before every test and relaunches within the test allowance (R15), the flaky terminal hint test is run 50 times under load and any failure is diagnosed and fixed (R44), and a rejected pairing offer and a rejected pairing credential are logged at `warn` with a fixed class and never a token (R45), each with its tests. +- Round: 1 +- Depends on: none +- Owns: `apps/server/src/diagnostics/host_metrics/volumes.rs`; `apps/server/src/auth/http.rs` (the pairing offer rejection log only); `apps/server/src/auth/service.rs` (the pairing credential rejection log in `consume_grant` only); `apps/server/tests/production_server_terminal_rpc.rs` (only if the R44 runs fail and the diagnosis lands in the test); `apps/desktop/e2e/wdio.conf.ts`; `apps/desktop/e2e/support/app-lifecycle.ts` and `apps/desktop/e2e/support/app-lifecycle.test.ts` +- Must not touch: `apps/server/src/terminal/**`, `apps/server/src/lifecycle.rs`, `apps/server/src/git/**`, `apps/web/**` +- Skills: `mstack-diagnose` (Task 7.3 is a decide-by-runs diagnosis); `tdd` (red-green for R14, R15, R45) +- Read first: `AGENTS.md` (completion rules, profiler coverage line, sensitive data, testing runbook maintenance for harness changes); `docs/architecture/overview.md` section on diagnostics and on auth (what the server logs and who reads it); `docs/testing/README.md` and the Linux and Windows desktop runbooks under `docs/testing/` (review them, because Task 7.2 changes a test procedure); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (R14, R15, R44, R45 are binding) +- Checkpoint: with the pinned toolchain first on PATH and `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`, `cargo test -p bibcode-server --lib host_metrics::volumes` and `cargo test -p bibcode-server --lib auth::http` and `cargo test -p bibcode-server --lib auth::service::tests::a_refused_pairing_credential` exit 0; `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` exits 0; `cargo fmt --all --check`, `cargo clippy -p bibcode-server --all-targets -- -D warnings`, `vp check` on the changed files and `vp run typecheck` exit 0; the R44 loop reports 0 failures in 50 runs (or the diagnosed fix does). Later proof, owned by Phase 8 and not by this phase: a packaged run in the Linux lab that kills the webview process in one spec and sees the next spec recover, and the relaunch time recorded in that run's report. + +## Contradictions to read before starting + +- R14 reverses two assertions in `volumes.rs`. The test `the_running_app_image_mount_is_hidden_before_it_is_read` asserts that `/tmp/.mount_OtherY` with `fuse.Other.AppImage` stays shown (`hidden.len() == 1`, `kept.len() == 3`) and that a `.mount_` mount is not hidden when no executable is known ("without the executable nothing matches"). R14 says the opposite for a read-only FUSE mount whose last component starts with `.mount_`. Task 7.1 rewrites those assertions on purpose; the fs-name-only marker (`fuse.` outside a `.mount_` directory) keeps requiring the executable. +- R45 names the "pairing rejection" log at `debug` in `auth/http.rs`. The only such log is `invalid_pairing_offer_response`, which logs `invalid pairing offer: {detail}` at `debug` while the client receives only `reason: "invalid_pairing_offer"`. That is the offer path. The case the issue saw ("Invalid pairing token" once on LAN pairing) is a rejected pairing credential: `consume_grant` in `auth/service.rs` returns `AuthError::InvalidCredential`, `HttpAuthError::into_response` logs nothing for it, and the client sees `reason: "invalid_credential"` for every cause. Task 7.4 covers both paths. + +## Tasks + +### Task 7.1: Hide any read-only FUSE mount under a `.mount_` directory + +- Satisfies: R14. Scenarios pinned: Given `/tmp/.mount_OtherY` with file system `fuse.BiBCode-1.0.AppImage` and the executable elsewhere, Then it is hidden. Given `/mnt/tools` with `fuse.gocryptfs`, Then it is shown. +- Depends on: none +- Files: modify `apps/server/src/diagnostics/host_metrics/volumes.rs`; test `apps/server/src/diagnostics/host_metrics/volumes.rs` (the `tests` module, next to `the_running_app_image_mount_is_hidden_before_it_is_read`) +- Interfaces: consumes `fn is_running_image(descriptor: &VolumeDescriptor, running_executable: Option<&Path>, appimage: Option<&Path>) -> bool` and `pub(crate) fn pre_filter(listed: Vec, running_executable: Option<&Path>, appimage: Option<&Path>) -> (Vec, Vec)`. Produces the same signatures with this behavior: a descriptor is hidden as `HiddenReason::System` when it is read-only, its file system is `fuse` or starts with `fuse.`, its mount point is not `/`, and either its mount point's last component starts with `.mount_` (no executable needed) or its file system equals `fuse.` and the running executable lies under the mount point. Hiding happens in `pre_filter`, before any space read, so a hidden mount is never read. +- Skills: tdd (red-green for the changed predicate) +- Read first: `apps/server/src/diagnostics/host_metrics/volumes.rs` `is_running_image`, `pre_filter` and the tests `the_running_app_image_mount_is_hidden_before_it_is_read`, `a_read_only_fuse_root_holding_the_executable_stays_shown`, `only_the_app_image_identity_hides_a_fuse_mount` (the contracts being changed and kept) +- Tier: standard +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib host_metrics::volumes` exits 0 and reports the three tests above passing. + +Steps + +1. [ ] In `volumes.rs` add the failing test `another_app_image_mount_is_hidden_whether_or_not_this_process_runs_from_it`. Cases, each through `pre_filter` with a read-only descriptor: (a) `/tmp/.mount_OtherY`, `fuse.BiBCode-1.0.AppImage`, executable `/home/test-user/bin/bibcode`, no `APPIMAGE`: expect one hidden volume with `HiddenReason::System`; (b) `/tmp/.mount_OtherY`, `fuse` and executable `None`: expect one hidden; (c) `/mnt/tools`, `fuse.gocryptfs`, executable `/home/test-user/bin/bibcode`: expect none hidden; (d) a writable `/tmp/.mount_OtherY` with `fuse.Other.AppImage`: expect none hidden; (e) a read-only `/` with `fuse.BiBCode-1.0.AppImage` and any executable: expect none hidden; (f) a read-only `/opt/image` with `fuse.BiBCode-1.0.AppImage`, `APPIMAGE` set to `/home/test-user/Apps/BiBCode-1.0.AppImage` and an executable outside `/opt/image`: expect none hidden (the fs-name marker still needs the executable). +2. [ ] Run `cargo test -p bibcode-server --lib host_metrics::volumes::tests::another_app_image_mount_is_hidden`. Expected: FAIL at case (a) with `hidden` empty. +3. [ ] Change `is_running_image` so the `runtime_mount` check no longer requires `running_executable`: hidden when `fuse`-family, read-only, mount point not `/`, and (`runtime_mount` or (`named_after_image` and the executable lies under the mount point)). Update its doc comment: say that every `.mount_*` read-only FUSE mount is an AppImage runtime mount of some instance, and name the cost (a user FUSE volume mounted under a `.mount_` directory is hidden). +4. [ ] Update the old assertions on purpose: in `the_running_app_image_mount_is_hidden_before_it_is_read` the `/tmp/.mount_OtherY` volume is now hidden (`hidden.len() == 2`, `kept.len() == 2`) and the last assertion becomes "a `.mount_` mount is hidden without a known executable"; keep its doc comment true to the new behavior. Leave `a_read_only_fuse_root_holding_the_executable_stays_shown` and `only_the_app_image_identity_hides_a_fuse_mount` as they are; both must still pass. +5. [ ] Run `cargo test -p bibcode-server --lib host_metrics::volumes`. Expected: PASS. +6. [ ] Prove the test can fail: restore the `running_executable` requirement on `runtime_mount`, run the command of step 5. Expected: FAIL on the new test. Restore the new code and run it again. Expected: PASS. +7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. +8. [ ] Profiler coverage line for the final report: none of the child process, RPC, worktree removal, binary, shutdown or event items is touched; the hidden-volume reason keeps using the existing `HiddenReason::System`. + +### Task 7.2: Check for a dead webview before every test and keep the relaunch inside the test allowance + +- Satisfies: R15. Scenario pinned: Given the webview of the shared app died in an earlier test, When the next test of the same worker starts, Then the harness relaunches the app once, inside the mocha timeout, and that test runs on the new app. +- Depends on: none +- Files: modify `apps/desktop/e2e/wdio.conf.ts`; modify `apps/desktop/e2e/support/app-lifecycle.ts`; test `apps/desktop/e2e/support/app-lifecycle.test.ts` +- Interfaces: consumes `recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise`, `ensureLiveDesktopUiWebview(): Promise`, `relaunchDesktopUiApplication(): Promise` and `stopRunningDesktopUiApplication(): Promise`. Produces exported constants in `app-lifecycle.ts`, with the numbers the fix uses: `DESKTOP_UI_TEST_TIMEOUT_MS = 120_000` (the value `mochaOpts.timeout` in `wdio.conf.ts` now reads from it), `DESKTOP_UI_STOP_BUDGET_MS = 10_000` (the existing SIGTERM wait), `DESKTOP_UI_EXIT_WAIT_MS = 20_000` (was 30 s, the wait for the old process to leave), `DESKTOP_UI_SERVE_WAIT_MS = 50_000` (was 90 s, the wait for WebDriver to answer), `DESKTOP_UI_PLUGIN_WAIT_MS = 20_000` (the existing `waitforTimeout` of the plugin wait), and `DESKTOP_UI_RELAUNCH_WORST_CASE_MS = 100_000`, the sum of the four waits that applies after a dead probe. The margin to the 120 s allowance is 20 s for session reload and mock restore. +- Skills: tdd (red-green for the no-memory behavior and the budget sum) +- Read first: `apps/desktop/e2e/wdio.conf.ts` hooks `before` and `beforeTest` and the comment on `desktopUiSpecs` (the nested array puts all shared specs in one worker, so `before` runs once for all of them); `apps/desktop/e2e/support/app-lifecycle.ts` `recoverDeadDesktopUiWebview`, `relaunchDesktopUiApplication`, `ensureLiveDesktopUiWebview`; `apps/desktop/e2e/support/app-lifecycle.test.ts` describe `recoverDeadDesktopUiWebview`; `docs/testing/linux-desktop.md` and `docs/testing/windows-desktop.md` (the harness procedure they describe) +- Tier: standard +- TDD: red-green +- Done when: `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` exits 0, and `rg -n "ensureLiveDesktopUiWebview" apps/desktop/e2e/wdio.conf.ts` shows a call inside `beforeTest` before `resetDesktopUiConnectionCache`. + +Steps + +1. [ ] In `app-lifecycle.test.ts` add the failing test `relaunches again when the webview dies a second time in the same worker`. Call `recoverDeadDesktopUiWebview` twice with the same dependency object whose `probe` rejects with `new Error("invalid session id")` both times (and a `relaunch` mock); expect `relaunch` called twice and both calls to resolve `true`. This pins that no once-per-worker state hides in the helper. Add the failing test `the relaunch waits fit inside the test allowance`: assert `DESKTOP_UI_STOP_BUDGET_MS + DESKTOP_UI_EXIT_WAIT_MS + DESKTOP_UI_SERVE_WAIT_MS + DESKTOP_UI_PLUGIN_WAIT_MS` equals `DESKTOP_UI_RELAUNCH_WORST_CASE_MS` and that value is at most `DESKTOP_UI_TEST_TIMEOUT_MS - 20_000`. +2. [ ] Run `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts`. Expected: FAIL with the new constants not exported (the first test may already pass; that is fine, it stays as proof). +3. [ ] In `app-lifecycle.ts` export the constants above and use them in `relaunchDesktopUiApplication` (the 30 s exit wait and the 90 s serve wait), in `stopRunningDesktopUiApplication` and `stopRelaunchedDesktopUiApplication` (the 10 s waits) and in the plugin `waitUntil`. Correct the comments: `ensureLiveDesktopUiWebview` is "the per-test probe of the shared app", and `recoverDeadDesktopUiWebview` runs "before a test", not "before a spec file". +4. [ ] In `wdio.conf.ts` call `await ensureLiveDesktopUiWebview()` as the first statement of `beforeTest`, before `resetDesktopUiConnectionCache`, and read `mochaOpts.timeout` from `DESKTOP_UI_TEST_TIMEOUT_MS`. Keep the call in `before` (the plugin-ready check needs a live webview at worker start) and fix its comment to say it covers worker start only. Add a line to the comment on `desktopUiSpecs`: the shared specs run in one worker, so `before` runs once for all of them and `beforeTest` carries the per-test probe. Expected: the config type-checks and the support test of step 1 passes. +5. [ ] Run `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts`. Expected: PASS. +6. [ ] Prove the test can fail: set `DESKTOP_UI_SERVE_WAIT_MS` to `90_000` temporarily and run the command of step 5. Expected: FAIL on the budget test. Restore the value and run it again. Expected: PASS. +7. [ ] Review `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md` and `docs/testing/macos-desktop.md` for any statement about when the dead-webview probe runs or about the 30 s and 90 s waits; correct them in this change if they exist, and otherwise write "reviewed and remain accurate" in the final report. +8. [ ] Run `vp check apps/desktop/e2e/wdio.conf.ts apps/desktop/e2e/support/app-lifecycle.ts apps/desktop/e2e/support/app-lifecycle.test.ts` and `vp run typecheck`. Expected: no errors. +9. [ ] Record in the final report that the 90 s serve wait was never measured and 50 s is a bound that keeps the sum under the allowance; Phase 8's packaged runs record the real relaunch time, and a relaunch that needs more than 50 s is a finding, not a reason to raise the wait silently. + +### Task 7.3: Run the flaky hint test 50 times under CPU load and diagnose any failure + +- Satisfies: R44. Scenario pinned: Given the test `terminal_command_activity_hint_decodes_strictly_without_entering_launch_env`, When it runs 50 times under CPU load, Then every run passes, or each failure is diagnosed and fixed in this batch. +- Depends on: none +- Files: modify `apps/server/tests/production_server_terminal_rpc.rs` (only if a run fails and the diagnosis lands in the test) +- Interfaces: consumes the test and its helpers in `production_server_terminal_rpc.rs`: `read_file_with_retry(path: &std::path::Path) -> String` (polls every 10 ms up to `TERMINAL_RPC_INTEGRATION_DEADLINE`, 5 s, and returns the first non-empty content), `environment_probe_command(output_path: &std::path::Path) -> (String, Vec)`, `request`, `assert_success`. Produces a recorded outcome in the final report: the number of failing runs out of 50, the load used, and for each failure the first line of its log. +- Skills: mstack-diagnose (decide by runs; no fix before the failure is classified) +- Read first: `apps/server/tests/production_server_terminal_rpc.rs` the test itself, `read_file_with_retry`, `environment_probe_command`, `TERMINAL_RPC_INTEGRATION_DEADLINE`; `docs/testing/README.md` (flaky test runbook) +- Tier: most capable +- TDD: exempt (a measurement; a code change happens only on a failure and then follows step 6) +- Done when: the loop of step 2 prints `failures: 0 of 50`, or the failures are classified and fixed and the loop is repeated and prints `failures: 0 of 50`. + +Steps + +1. [ ] Build once so the loop measures the test and not the compiler: with the pinned toolchain first on PATH, run `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3 cargo test -p bibcode-server --test production_server_terminal_rpc --no-run`. Expected: exit 0. +2. [ ] Start one busy loop per CPU core, run the test 50 times, then stop the load. Use a log directory outside the repository (the session scratchpad). Command: `load=(); for i in $(seq "$(sysctl -n hw.ncpu)"); do yes >/dev/null & load+=($!); done; fail=0; for run in $(seq 1 50); do cargo test -p bibcode-server --test production_server_terminal_rpc terminal_command_activity_hint_decodes_strictly_without_entering_launch_env -- --exact >"$LOGDIR/run-$run.log" 2>&1 || { fail=$((fail+1)); echo "run $run failed"; }; done; kill "${load[@]}"; echo "failures: $fail of 50"`. Expected: `failures: 0 of 50`. On Linux use `nproc` for `sysctl -n hw.ncpu`. +3. [ ] If there are no failures, record "0 of 50 under one busy loop per core, no code change" in the final report and stop. The test stays as it is. +4. [ ] If a run failed, classify it from its log by the first panic line. (a) `timed out waiting for` the probe file: the 5 s `TERMINAL_RPC_INTEGRATION_DEADLINE` is reached by shell start-up under load. (b) `assertion left == right` on `"command|runtime|runtime|clean"`: the poll returned content before the probe finished or the content is wrong. (c) A failure in `request`, `assert_success` or the invalid-activity cases after the first step: a server-side defect or another deadline. Expected: the failure is put in exactly one of the three classes, or logged as a fourth with its first panic line. +5. [ ] Reproduce the classified failure alone: re-run only the failing configuration with load until it fails at least twice in 20 runs, so the later fix can be shown to remove it. +6. [ ] Apply the fix for the class. (a) and (b): in the test file only, make `read_file_with_retry` poll until the content equals the expected text or a 30 s guard elapses, so only a real hang reaches the guard (the same approach as R13's drain wait), and keep `TERMINAL_RPC_INTEGRATION_DEADLINE` for the other waits. (c): stop and report to the coordinator, because the server paths under `apps/server/src/terminal/**` belong to other phases. +7. [ ] Repeat step 2. Expected: `failures: 0 of 50`. +8. [ ] If the test file changed, run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. + +### Task 7.4: Log a rejected pairing offer and a rejected pairing credential at `warn` with a fixed class + +- Satisfies: R45. Scenarios pinned: Given a pairing offer request that the server rejects, Then one `warn` line carries the trace id and the reason text. Given a pairing credential that `consume_grant` refuses, Then one `warn` line carries the class of the refusal and nothing that identifies the credential, a device, an address or a path. +- Depends on: none +- Files: modify `apps/server/src/auth/http.rs` (`invalid_pairing_offer_response`); modify `apps/server/src/auth/service.rs` (`consume_grant`); test `apps/server/src/auth/http.rs` (the existing `tests` module); test `apps/server/src/auth/service.rs` (the `tests` module, next to the persisted-store tests that use `AuthService::new_with_persistence`) +- Interfaces: consumes (a) `fn invalid_pairing_offer_response(detail: &str) -> Response` in `http.rs`, whose callers pass a fixed literal or `PairingOfferInputError::to_string()` (fixed `thiserror` messages that hold no input text); (b) `async fn consume_grant(&self, credential: &str, proof_key_thumbprint: Option<&str>) -> Result` in `service.rs`, with its three rejection sites; (c) the existing `Repositories::get_auth_pairing_link_by_credential(&self, credential: String) -> Result>`, which selects the row with its `expires_at`, `consumed_at`, `revoked_at` and `proof_key_thumbprint` columns. Produces: `invalid_pairing_offer_response` logging at `warn` with the same target, `trace_id` field and message; and a private `fn pairing_rejection_class(...) -> &'static str` in `service.rs` returning one of these fixed tokens, with this precedence when several apply: `empty` (the trimmed credential is empty), `unknown` (no row or no in-memory pairing holds it), `revoked`, `already_used`, `expired`, `proof_key_mismatch` (the link requires a proof key and the presented thumbprint differs or is absent), `desktop_bootstrap_expired` (the desktop bootstrap credential matched but its time ran out), `unclassified` (the extra read failed). Each refusal logs `tracing::warn!(target: "bibcode_server::auth", class, "a pairing credential was refused")`, once, before `Err(AuthError::InvalidCredential)` is returned. The HTTP response is unchanged for both paths. +- What the code can tell apart today, with no schema change: in the in-memory path (`self.repositories` is `None`) every class above, because the pairing record is in hand. In the persisted path, `consume_auth_pairing_link` runs one `UPDATE ... WHERE credential = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND (proof_key_thumbprint IS NULL OR proof_key_thumbprint = ?) RETURNING ...` and returns `None` for every cause, so the coarse result is "refused". To classify, `consume_grant` makes one extra read on the refusal path only, `get_auth_pairing_link_by_credential`, which reads columns that already exist (no migration). A row consumed by a racing request between the two statements is reported as `already_used`, which is true. The credential column holds the credential text, so the lookup uses the presented text and the text is never logged. A credential too malformed to be told from an unknown one (any wrong text) is `unknown`; only the empty string is told apart as `empty`. +- Skills: tdd (red-green for the log level, the class and the absence of the secret) +- Read first: `apps/server/src/auth/service.rs` `consume_grant` and the persisted-store tests near `new_with_persistence` (the fixture to reuse: `mcp_test_service()` builds a service over a temporary database); `apps/server/src/persistence/repositories.rs` `consume_auth_pairing_link`, `PAIRING_RETURNING_SQL` and `get_auth_pairing_link_by_credential`; `apps/server/src/auth/http.rs` `invalid_pairing_offer_response` and its callers; `apps/server/tests/provider_terminal_supervisor.rs` `TraceCapture` (the tracing capture other server tests use: a `MakeWriter` over `Arc>>` installed with `tracing::subscriber::set_default`; no shared helper exists, so copy it into each `tests` module) +- Tier: standard +- TDD: red-green +- Done when: `cargo test -p bibcode-server --lib auth::http::tests::a_rejected_pairing_offer_is_logged_at_warn` and `cargo test -p bibcode-server --lib auth::service::tests::a_refused_pairing_credential` exit 0. + +Steps + +1. [ ] In each `tests` module add the capture writer (copy of `TraceCapture` and `TraceCaptureWriter`), installed with `tracing_subscriber::fmt().with_writer(capture).with_ansi(false).with_max_level(tracing::Level::DEBUG)` through `tracing::subscriber::with_default`, so a `debug` line is also seen. +2. [ ] In `http.rs` write the failing test `a_rejected_pairing_offer_is_logged_at_warn_with_its_reason_and_without_the_token`. Call `validate_pairing_offer_input("Test Device", "ftp://example.test/?token=pairing-secret-marker", "another-device", None)`, pass `&error.to_string()` of its `Err` to `invalid_pairing_offer_response` as the handler does, and assert the captured text contains `WARN`, `endpoint must be an http(s) URL` and `trace_id=`, and does not contain `pairing-secret-marker`. Add the case `invalid_pairing_offer_response("idempotency key is invalid")` expecting `WARN`. Assert the status is `400` and the JSON `reason` is still `invalid_pairing_offer`. +3. [ ] In `service.rs` write the failing test `a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential`, a table over both paths (in-memory service and the `mcp_test_service()`-style persisted service). Per path, create pairing links through the service's existing issuance call with the credential text `pairing-secret-marker-`, then drive each case and call `consume_grant` (or the public `create_browser_session`): `unknown` (present `pairing-secret-marker-none`), `empty` (present `""`), `revoked` (revoke the link first), `already_used` (consume once, then again), `expired` (a link issued with an expiry already past, or the clock-controlled equivalent the existing tests use), `proof_key_mismatch` (a link bound to a thumbprint, presented with a different one). Add `desktop_bootstrap_expired` on the in-memory service built with an expired desktop bootstrap. For each case assert the captured text has exactly one `WARN` line with `class=` and `a pairing credential was refused`, and does not contain `pairing-secret-marker`. Assert the call still returns `Err(AuthError::InvalidCredential)`. +4. [ ] Run `cargo test -p bibcode-server --lib auth::http::tests::a_rejected_pairing_offer_is_logged_at_warn` and `cargo test -p bibcode-server --lib auth::service::tests::a_refused_pairing_credential`. Expected: FAIL; the first because the line is `DEBUG`, the second because no `WARN` line exists. +5. [ ] Change `tracing::debug!` to `tracing::warn!` in `invalid_pairing_offer_response`, nothing else there. +6. [ ] In `service.rs` add `pairing_rejection_class`, and log through it at the three refusal sites of `consume_grant` (desktop bootstrap expired, persisted `None` after the extra read, in-memory refusal). On the persisted path the extra read runs only when the update returned `None`; if it errors, use `unclassified`. Do not log the credential, a hash of it, the link id, the label, the subject, an address or a path. +7. [ ] Run the two commands of step 4. Expected: PASS. +8. [ ] Prove the tests can fail: temporarily put the credential text into the `warn!` call, run the service test. Expected: FAIL on the "never the credential" assertion. Remove it. Temporarily change `warn!` back to `debug!` in `invalid_pairing_offer_response`, run the http test. Expected: FAIL. Restore both and run again. Expected: PASS. +9. [ ] Run `cargo test -p bibcode-server --test auth_http` (response bodies unchanged). Expected: PASS. +10. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. +11. [ ] Profiler coverage line for the final report: refusals are logged through `tracing::warn!` at an operational boundary, as `AGENTS.md` asks; nothing goes through `profiler::emit` or `record_finding`; the class is a fixed token and no content, token, hash, device name, address or path is logged. Note the cost: one extra indexed read on a refused credential, which an unauthenticated caller can trigger, so keep it a single query and add no retry. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md new file mode 100644 index 000000000..0bc9be895 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md @@ -0,0 +1,95 @@ +# Phase 8: Lab runs and bounded probes + +- Goal: Every requirement whose proof needs a packaged app, another operating system or a real pair has a recorded result, and each "decide by probe" item is decided. +- Round: 2 +- Depends on: 1, 2, 3, 4, 5, 6, 7 +- Owns: nothing in the repository. Lab scripts, logs, screenshots and probe scripts stay in the session scratch folder. +- Must not touch: product source under `apps/web/src`, `apps/server/src`, `packages/**`. A defect found here goes back to the phase that owns the file, as a new task there. +- Skills: bibcode-profiler (Profiler is on in every run and its data is read); mstack-diagnose (any failing run is diagnosed before a fix) +- Read first: `docs/testing/README.md`, `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md`, `docs/testing/cross-platform-validation.md` (the real lab procedures); `docs/skills/bibcode-profiler/SKILL.md` (how to turn the Profiler on and read its findings); `AGENTS.md` (no manual steps for the user; sensitive data) +- Checkpoint: `progress.md` holds one log line per task below with the command, the counts and the profiler findings, each finding classed as caused by this change, pre-existing or a test artifact. + +Rules for every task in this phase: the Profiler is on; a run's numbers go into the plan log, never into a living runbook; nothing is downloaded except this repository's own release assets through `gh release download`; no run touches the user's live BiBCode instance or their own Git host logins. + +## Tasks + +### Task 8.1: Linux lab run of the packaged list, with the two discriminating runs + +- Satisfies: R12 (the 1000x720 run), R20, spec Verification (Linux lab) +- Depends on: 5.2, 7.2 +- Files: none in the repository +- Interfaces: consumes the Linux lab image `bibcode-linux-lab:22.04` (rebuild it from `docs/testing/linux-desktop.md` and the Linux leg of `.github/workflows/desktop-ui-smoke.yml` when it is missing); produces log lines in `progress.md` +- Skills: bibcode-profiler (findings of the run are classified) +- Read first: `docs/testing/linux-desktop.md` (build and run procedure); `.github/workflows/desktop-ui-smoke.yml` (the exact packaged commands) +- Tier: standard +- TDD: exempt (a pure command run with recorded results) +- Done when: the packaged default list passes in the lab, `chat-reasoning-rows` passes at a 1000x720 viewport, and the R20 numbers are in the log + +Steps + +1. [ ] Check free disk with `df -h /`. Expected: at least 60 GB free; otherwise stop and report. +2. [ ] Build the branch head in the lab with `CARGO_BUILD_JOBS=4`. Expected: the packaged app builds. +3. [ ] Run the packaged default list, Profiler on, with `GST_PLUGIN_PATH` set as the runbook says. Expected: every spec passes; a failure is diagnosed, not rerun blindly. +4. [ ] Run `chat-reasoning-rows` with the window at 1000x720. Expected: PASS (before Phase 5's fix this run was the discriminator; the baseline result is in the log from the start of execution, see plan decision P3). +5. [ ] R20: in one packaged run, switch between Settings and a thread ten times, then read the profiler data. Expected: record the longest main-thread stall and the web process's memory at start and end. A `client.stall` or `memory.leak-suspect` or `memory.leak-likely` finding that carries a trace opens a diagnosis task in the phase that owns the traced code; with no such finding, log "no finding with a trace; nothing changed". +6. [ ] Log every profiler finding of the run with its class (caused by this change, pre-existing, test artifact). + +### Task 8.2: Mixed-version probe against the previous release's server + +- Satisfies: R19 +- Depends on: 1.6 +- Files: none in the repository (the probe script lives in the session scratch folder) +- Interfaces: consumes `scripts/resolve-previous-release-tag.ts` (the previous release tag), that release's Linux server asset, and the current `@bibcode/client-runtime` terminal RPC functions; produces a log line with each call's outcome +- Skills: mstack-diagnose (a failing legacy path is diagnosed before any fix) +- Read first: `scripts/resolve-previous-release-tag.ts` (how the tag is resolved); `packages/client-runtime/src/state/terminal.ts` (the attach, resize and keybinding calls); `docs/testing/cross-platform-validation.md` sections on older servers (what "legacy path" means for each call) +- Tier: most capable +- TDD: exempt (a probe whose result is recorded; a failure becomes a red-green task in the owning phase) +- Done when: `rg -n "8.2 Closed" docs/plans/2026-11-13-issue-batch-348-358/progress.md` finds the log line, and it states, for attach, resize and keybindings, "legacy path, no error" or names the failing call and the new task that fixes it + +Steps + +1. [ ] Resolve the previous release tag with `node scripts/resolve-previous-release-tag.ts`. Expected: a tag older than this branch's version. +2. [ ] Inside the Linux lab container, fetch that release's server asset with `gh release download ` from this repository only, into a new empty directory, and start it headless on a loopback port with an isolated data home. Expected: the server answers its health route. +3. [ ] From a node script that imports the current client runtime, connect, read the server's capabilities, then call terminal attach, resize and the keybindings read. Expected: capabilities hold no `terminalSizeHandOver`; each call returns through its legacy path with no error. +4. [ ] Stop the server by its own PID and remove only the directory this task created. Expected: no leftover process. +5. [ ] Log the outcome. A failing call opens a task in the phase that owns the failing code. + +### Task 8.3: Windows VM runs + +- Satisfies: R12 (Windows proof), R13 (12 whole-suite runs), spec Verification (Windows) +- Depends on: 5.2, 3.1 +- Files: none in the repository +- Interfaces: consumes the Windows 11 arm64 VM through the wrapper procedure in `docs/testing/windows-desktop.md`; produces log lines in `progress.md` +- Skills: bibcode-profiler (the packaged runs have the Profiler on) +- Read first: `docs/testing/windows-desktop.md` (build, test and packaged procedures for Windows) +- Tier: standard +- TDD: exempt (a pure command run with recorded results) +- Done when: the log holds `chat-reasoning-rows` PASS at the viewport where it failed (1000x720 CSS px), 12 of 12 whole-suite passes of `cargo test -p bibcode-server` for the removal test, and exit 0 for fmt, Clippy, `vp check` and typecheck + +Steps + +1. [ ] Copy the branch head into the VM as the runbook says and build. Expected: the build succeeds. +2. [ ] Run `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `vp check`, `vp run typecheck`. Expected: exit 0 each. +3. [ ] Run `cargo test -p bibcode-server` 12 times, one after the other, with no other load-heavy job on the Mac. Expected: `removal_refuses_when_an_admission_does_not_drain_before_the_deadline` passes in 12 of 12; any other failure is logged with its name and compared with `main`. +4. [ ] Run the packaged `chat-reasoning-rows` spec. Expected: PASS; the expanded header moves at most 2 px. +5. [ ] Log the results and the profiler findings with their class. + +### Task 8.4: Load runs on the Mac + +- Satisfies: R13 (50 runs alone under load) +- Depends on: 3.1 +- Files: none in the repository +- Interfaces: consumes the test name `removal_refuses_when_an_admission_does_not_drain_before_the_deadline`; produces a log line with the pass count +- Skills: none +- Read first: `docs/testing/flaky-tests.md` (how repeated runs under load are done and reported) +- Tier: cheap +- TDD: exempt (a pure command run with recorded results) +- Done when: the loop of `cargo test -p bibcode-server --lib removal_refuses_when_an_admission_does_not_drain_before_the_deadline` reports 50 passes and 0 failures, and the log holds that count + +Steps + +1. [ ] Start CPU load with one busy process per core, keep their PIDs, and never stop processes by name. Expected: load is visible in `uptime`. +2. [ ] Run `cargo test -p bibcode-server --lib removal_refuses_when_an_admission_does_not_drain_before_the_deadline` 50 times in a loop that counts exit codes. Expected: 50 passes, 0 failures. +3. [ ] Stop exactly the PIDs from step 1. Expected: none left. +4. [ ] Log the count. A failure reopens Task 3.1. + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md new file mode 100644 index 000000000..19d9129ad --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md @@ -0,0 +1,92 @@ +# Phase 9: Living docs, final gates and the pull request + +- Goal: The living documentation matches the changed behavior, every completion gate of `AGENTS.md` has fresh evidence, and one pull request is open that closes exactly the issues whose every item has evidence. +- Round: 3 +- Depends on: 1, 2, 3, 4, 5, 6, 7, 8 +- Owns: `docs/skills/bibcode-profiler/references/events.md`, `docs/testing/**` (living runbooks only, not `reports/`), `docs/architecture/overview.md`, `docs/architecture/rpc-and-orchestration.md`, `CHANGELOG.md` +- Must not touch: product source and tests (a failing gate goes back to the phase that owns the file) +- Skills: better-writing (plain, exact wording in docs and the pull request body) +- Read first: `AGENTS.md` (Task Completion Requirements, Testing Runbook Maintenance, Profiler coverage, Sensitive Data); `docs/README.md` (the docs index and what counts as living documentation); `spec.md` (the dispositions each issue needs before it may be closed) +- Checkpoint: `vp run check:markdown-links` exits 0, every command of Task 9.2 exits 0, and the pull request's `Closes` list equals the issues whose items all have a log line with evidence. + +## Tasks + +### Task 9.1: Profiler and validation references + +- Satisfies: R24, R25, and the documentation parts of R26 and R32 +- Depends on: 2.4, 3.3 +- Files: modify `docs/skills/bibcode-profiler/references/events.md`; modify `docs/testing/cross-platform-validation.md` +- Interfaces: consumes the quiesce step name `pull-request-lookups` (Task 2.4), the removal mode `create-rollback` with reason token `internal` (Task 3.3) +- Skills: better-writing (reference entries that state fields and timing exactly) +- Read first: `apps/server/src/production/runtime.rs` quiesce sequence (the real order of steps); `.github/workflows/ci.yml` test filter lines (the filters CI really runs) +- Tier: cheap +- TDD: exempt (documentation only) +- Done when: `rg -n "git-writes|pull-request-lookups" docs/skills/bibcode-profiler/references/events.md` finds both in the `server.stop` step list, `rg -n "create-rollback" docs/skills/bibcode-profiler/references/events.md` finds the mode, and `rg -n "background_status_|git_stop_|index_lock_cleanup_" docs/testing/cross-platform-validation.md` finds all three filters + +Steps + +1. [ ] In `events.md`, list `git-writes` and `pull-request-lookups` among the `server.stop` quiesce steps in the order `runtime.rs` runs them. Expected: the list matches the code's order. +2. [ ] In `events.md`, document the `worktree.removal` mode `create-rollback` and its reason token. Expected: the names equal the emitted ones. +3. [ ] In `cross-platform-validation.md`, add the three test filters next to the `quiesce_in_` line, with the note that a filter matching no test fails the step. Expected: the command equals the one in `ci.yml`. +4. [ ] Run `vp run check:markdown-links`. Expected: exit 0. + +### Task 9.2: Architecture docs, runbooks and changelog + +- Satisfies: R5, R41 (documented behavior), spec Verification (runbooks reviewed) +- Depends on: 9.1, 8.3 +- Files: modify `docs/architecture/overview.md`; modify `docs/architecture/rpc-and-orchestration.md`; modify `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md` and `docs/testing/macos-desktop.md` only where a procedure changed; modify `CHANGELOG.md` +- Interfaces: consumes the `terminalSizeHandOver` capability (Task 1.6) and the Unicode 11 width mode (Phase 1) +- Skills: better-writing (states guarantees and limits without marketing words) +- Read first: `docs/architecture/overview.md` terminal and Profiler sections (the text that now changes) +- Tier: standard +- TDD: exempt (documentation only) +- Done when: `vp run check:markdown-links` exits 0; `rg -n "terminalSizeHandOver" docs/architecture` finds the capability; the changelog's unreleased section names each user-visible change of this batch; and the task's log line lists each runbook as changed or "reviewed and remains accurate" + +Steps + +1. [ ] Update the terminal section of `overview.md`: terminals count widths by Unicode 11, a new terminal retries WebGL after an earlier failure, and the development size record exists and holds no content. Expected: each statement matches the code. +2. [ ] Update `rpc-and-orchestration.md` with the `terminalSizeHandOver` capability and what a client does without it. Expected: names equal the contracts in `packages/contracts/src`. +3. [ ] Review the three desktop runbooks against the changed procedures (the per-test webview probe). Change what is stale; record the others as reviewed and accurate. Expected: no execution-specific numbers are added to a living runbook. +4. [ ] Add the unreleased changelog entries, one line per user-visible change, in the file's existing style. Expected: no issue is described as fixed that the log does not prove. +5. [ ] Run `vp run check:markdown-links`. Expected: exit 0. + +### Task 9.3: Final gates and reviews + +- Satisfies: support (the spec's Verification section, for every task of phases 1 to 8) +- Depends on: 9.2 +- Files: none (a failing gate reopens the owning task) +- Interfaces: consumes the integrated branch; produces the validation table for the pull request body +- Skills: vercel-react-best-practices (required review of every changed React component and hook); better-interface (the `UI.md` review of every changed screen, dialog and notice) +- Read first: `UI.md` (the design-quality review); `AGENTS.md` Task Completion Requirements (the full list) +- Tier: most capable +- TDD: exempt (a pure command run and two reviews with recorded results) +- Done when: each command below exits 0 on the integrated head and the log holds the React review, the `UI.md` review, the profiler coverage line and the sensitive-data check + +Steps + +1. [ ] Put the pinned Rust toolchain first on `PATH` and run `cargo fmt --all --check`. Expected: exit 0. +2. [ ] Run `cargo clippy --workspace --all-targets -- -D warnings`. Expected: exit 0. +3. [ ] Run `cargo test -p bibcode-server` and `cargo test -p bibcode-desktop`. Expected: 0 failed. +4. [ ] Run `vp check --no-fmt`, `vp check` on the changed files, `vp run typecheck`, `vp run test`, `vp run check:contracts` and `vp run check:markdown-links`. Expected: exit 0 each (the app's own local settings file may fail whole-tree formatting; it is not part of the change). +5. [ ] Review every changed component and hook under `apps/web` against `vercel-react-best-practices`, and every changed screen, dialog, notice and message against `UI.md`. Expected: each finding is fixed in its owning task or logged with the reason it stays. +6. [ ] Write the profiler coverage line: which items of the `AGENTS.md` coverage list the batch touches (a new quiesce step, a new removal mode, new warn logs) and how each stays covered. Expected: one line per item. +7. [ ] Read `git diff main...HEAD --stat` and `git status --short`, and search the added lines, commit messages and plan files for local machine paths, private hosts, real names and credentials. Expected: none; no generated or debug file is in the diff. + +### Task 9.4: Reviews before the push, then the pull request + +- Satisfies: support (D1: one pull request, for every task of phases 1 to 8) +- Depends on: 9.3 +- Files: none +- Interfaces: consumes the validation table of Task 9.3 and the lab tables of Phase 8; produces one pull request +- Skills: better-writing (a pull request body that states limits and what was not run) +- Read first: the body of the previous batch's pull request (the house format: per-issue table, limits, validation table, not run, profiler, runbooks) +- Tier: most capable +- TDD: exempt (review and publication, no behavior) +- Done when: the pull request exists, its `Closes` list names only issues whose every item has evidence in the log, the others are listed under `Refs` with what is missing, and the session is bound to the pull request with CI followed by the app's Auto-fix + +Steps + +1. [ ] Get a read-only Codex review of the whole branch against `main` and a second opinion from the advisor; verify each finding against the code before accepting it. Expected: every accepted finding is fixed with a test that failed first, and every rejected one has its reason in the log. +2. [ ] Rerun the commands of Task 9.3 that the fixes can affect. Expected: exit 0. +3. [ ] Push the branch once (a later push cancels the running CI) and open the pull request with the house format. Expected: the body has no local path, private host or real name. +4. [ ] Bind the pull request to the session and let Auto-fix do the waiting; a cheap helper reads failed job logs and bot comments, and only decisions come back to the main session. Expected: no auto-merge is turned on. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/plan.md b/docs/plans/2026-11-13-issue-batch-348-358/plan.md new file mode 100644 index 000000000..ea9288452 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/plan.md @@ -0,0 +1,188 @@ +# Issue batch #348 and #351 to #358: terminal panel, Git, worktree, update guard and web follow-ups + +Status: Draft +Spec: spec.md (Status line: Draft, sha256 2df2631120ee) +Repository: this repository +Base: main + +## Goal + +1. A terminal repaints with the right glyph metrics after a late font load or a late creation, counts widths the way current programs do, and keeps a content-free size record in development. +2. Expanding a reasoning row that the reader brought into view leaves the chat where it was, on Windows too. +3. Every item of #352 to #358 is fixed with a failing-first test, decided by a recorded test or probe, or recorded as a limit in code or docs. + +#350 (the server-side terminal screen) is not in this plan. Its folder is `../2026-11-13-terminal-screen-350/`; its design failed its first review and is being reworked (spec D2). + +## Global constraints + +- xterm.js stays at 6.0.0; the only new dependency is `@xterm/addon-unicode11` 0.9.0 (spec Non-goals, R5). +- Failure backoff for a lookup: 5 seconds. Remembered pull request: cleared by a failed lookup when the last success is more than 5 minutes old (D6, D7). +- `pull` stays out of `takes_index_lock` (D5). Tooltips take no occlusion lease (D11). +- A creation record is reaped only when its folder is missing and the folder's parent directory exists (D9). +- New capability name: `terminalSizeHandOver`, decoding default `false` (R41). New quiesce step name: `pull-request-lookups` (R26). New removal mode: `create-rollback` (R32). +- Size record: last 200 records, no terminal content (R4). +- No test may depend on a wall-clock bound that load can exceed (Goal 5). +- Placeholders only in tests, fixtures, docs, commits and the pull request: `gitlab.example.test`, `example-group/sample-project`, `Test User` (D14, AGENTS.md Sensitive Data). +- Rust toolchain 1.98.1 first on `PATH`; Rust writers set `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`; at most 4 Rust writers at once and at least 60 GB of free disk before a round starts. + +## Review focus + +1. A mirror window must never reflow at its own size when it repaints after a font load. Pinned by Task 1.1 (a fit mock that really resizes). +2. Shutdown order: lookups end before `git-writes` and the profiler teardown, and a lookup admitted after shutdown cannot panic or hang. Pinned by Task 2.4. +3. A cancelled lookup must not be remembered as a failure, or a leaving caller poisons the key for 5 seconds. Pinned by Task 2.5. +4. A failed desktop state query must never release the unsaved-edits block. Pinned by Task 4.2. +5. A popup kept mounted while closed must hold no occlusion lease, or the native preview stays hidden. Pinned by Task 6.2. + +## Out of scope + +- The server-side terminal screen of #350: own folder, design in rework (D2). +- Replacing or upgrading xterm.js: no evidence it fixes these items. +- A CI lane for a new client against an older server binary: Task 8.2 runs the check once. +- Git sign-in from the client, new update-install UI beyond the dialog's exit, a creation-record schema change (D10). +- Speculative fixes for lab observations whose bounded probe gives no signal (R20, R43, R44). + +## Sources + +- Repository rules: `AGENTS.md` (pre-work, completion gates, profiler coverage, sensitive data, runbook maintenance); `UI.md` (review of every user-visible change). +- Repository docs: `docs/README.md` (docs index); `docs/architecture/overview.md` (terminal, Profiler, worktree and Source Control sections); `docs/reference/workspace-layout.md` and `docs/reference/scripts.md` (where code and scripts live, real commands); `docs/testing/*.md` (lab procedures); `docs/skills/bibcode-profiler/references/events.md` (events to extend); `docs/plans/2026-11-09-issue-batch-340-345-290/` (the batch these issues follow up; its spec's R16 is the index-lock ownership rule). +- Issues: `sources/issue-*.md` (exported 2026-11-13); `evidence.md`; `triage.md`. +- External: none. + +## Stack and commands + +- Server and desktop host: Rust 1.98.1 (Axum, Tokio, Tauri 2). `cargo test -p bibcode-server --lib `, `cargo test -p bibcode-server --test `, `cargo test -p bibcode-desktop`, `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`. +- Web: React with Vite+. From `apps/web`: `vp test run --project unit ` (DOM tests through their `*.dom.test.tsx` companion). Repository root: `vp check`, `vp run typecheck`, `vp run test`, `vp run check:contracts`, `vp run check:markdown-links`, `vp test run scripts`. +- Packaged desktop e2e (WebdriverIO): `vp run test:ui:desktop:build`, then `vp run test:ui:desktop`. +- Checked on 2026-11-13: `vp install` ran clean on the base commit. The test commands were taken from `package.json`, `.github/workflows/ci.yml` and the testing runbooks and were not run during planning (the first server test build takes about ten minutes); each phase's first test step runs them. + +## Phases + +| Phase | File | Goal | Round | Depends on | Tier | +|---|---|---|---|---|---| +| 1 | phases/PHASE-01-terminal-panel-fixes.md | Terminal panel: late font and late creation repaint, size record, Fit feedback, hand-over capability, WebGL retry, Unicode 11 widths, two decide-by-test items | 1 | none | most capable | +| 2 | phases/PHASE-02-git-and-pull-request-lookups.md | Git lock limits, sign-in detector, lookup teardown, backoff and expiry | 1 | none | most capable | +| 3 | phases/PHASE-03-worktree-records-and-removal-test.md | Creation records by id, audited rollbacks, unmounted-volume gate, deterministic drain test | 1 | none | most capable | +| 4 | phases/PHASE-04-update-install-guard.md | Update install guard across reload, lost event and late sources; event-name guard | 1 | none | standard | +| 5 | phases/PHASE-05-reasoning-row-scroll-and-dashboard.md | Reasoning row keeps its place; Host dashboard values view stops re-rendering | 1 | none | most capable | +| 6 | phases/PHASE-06-popups-picker-and-markdown-images.md | Popup occlusion by rectangle, model picker reopen, read-only Markdown images | 1 | none | standard | +| 7 | phases/PHASE-07-server-odds-and-e2e-harness.md | AppImage mounts hidden, per-test webview probe, flaky hint test, pairing refusal logs | 1 | none | standard | +| 8 | phases/PHASE-08-labs-and-probes.md | Linux lab, Windows VM, mixed-version probe and load runs, with the Profiler on | 2 | 1, 2, 3, 4, 5, 6, 7 | most capable | +| 9 | phases/PHASE-09-docs-gates-and-pull-request.md | Living docs, final gates, reviews and the pull request | 3 | 1, 2, 3, 4, 5, 6, 7, 8 | most capable | + +Phases in the same round run in parallel. A phase depends only on phases in earlier rounds. + +## File map + +| Path or external change | Action | Phase | Verification | +|---|---|---|---| +| `apps/web/src/components/ThreadTerminalPanel.tsx` and `ThreadTerminalPanel.interactions.test.tsx` | modify | 1 | from `apps/web`: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx` | +| `apps/web/src/components/terminalSizeDiagnostics.ts` and its test | create | 1 | same command with the new test path | +| `apps/web/src/components/terminalSizing.ts`, `terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts` | modify (only if a task needs it) | 1 | their existing tests | +| `apps/web/package.json` (dependency `@xterm/addon-unicode11` 0.9.0) | modify | 1 | `vp install`, then the R5 xterm test | +| `packages/contracts/src/environment.ts`, `packages/contracts/fixtures/**`, `packages/shared/src/testSupport.ts` | modify | 1 | `vp run check:contracts` | +| `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs` | modify | 1 | `cargo test -p bibcode-server --lib lifecycle` | +| `apps/server/src/git/process.rs`, `apps/server/src/git/sign_in.rs` | modify | 2 | `cargo test -p bibcode-server --lib sign_in:: index_lock_cleanup_` | +| `apps/server/src/source_control/open_pull_request_lookups.rs`, `apps/server/src/source_control/pull_request.rs` | modify | 2 | `cargo test -p bibcode-server --lib open_pull_request_lookups::` | +| `apps/server/src/git/broadcaster.rs`, `apps/server/src/production/git_vcs.rs`, `apps/server/src/production/runtime.rs` | modify | 2 | Phase 2 checkpoint | +| `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/diagnostics/profiler/worktree_audit.rs` | modify | 3 | `cargo test -p bibcode-server --lib worktree` | +| `apps/server/src/persistence/repositories.rs`, `apps/server/tests/repositories.rs`, `apps/server/src/production/orchestration_effects.rs`, `apps/server/tests/profiler_worktree_audit.rs`, `apps/server/src/mcp/tools/worktrees.rs` | modify | 3 | `cargo test -p bibcode-server --test repositories` and `--test profiler_worktree_audit` | +| `apps/web/src/components/files/unsavedFileEdits.ts`, `UnsavedChangesCloseDialog.tsx` and their tests; `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` and its test (only if Task 4.4 finds the action unreachable) | modify | 4 | Phase 4 checkpoint | +| `scripts/desktop-event-names.test.ts` | create | 4 | `vp test run scripts/desktop-event-names.test.ts` | +| `apps/web/src/components/ChatView.tsx` and `ChatView.test.tsx`; `apps/web/src/components/chat/timelineScrollAnchoring.ts`, `MessagesTimeline.logic.ts` and their tests | modify | 5 | Phase 5 checkpoint | +| `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and its test | modify | 5 | Phase 5 checkpoint | +| `apps/web/src/browser/browserSurfaceStore.ts`, `browserSurfaceSync.ts` and their tests | modify | 6 | from `apps/web`: `vp test run --project unit src/browser` | +| `apps/web/src/components/ui/useNativePreviewOcclusion.ts`, `popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx` | modify | 6 | the new popup occlusion DOM test | +| `apps/web/src/components/ui/usePopupOcclusion.ts` and its DOM test | create | 6 | the new popup occlusion DOM test | +| `apps/web/src/components/chat/ProviderModelPicker.tsx` and its test; `apps/web/src/components/ChatMarkdown.tsx` and its test; `apps/web/src/components/files/FilePreviewPanel.tsx` and its test | modify | 6 | Phase 6 checkpoint | +| `apps/server/src/diagnostics/host_metrics/volumes.rs`; `apps/server/src/auth/http.rs`, `apps/server/src/auth/service.rs` | modify | 7 | Phase 7 checkpoint | +| `apps/server/tests/production_server_terminal_rpc.rs` (only if the R44 runs fail there) | modify | 7 | the 50-run loop of Task 7.3 | +| `apps/desktop/e2e/wdio.conf.ts`, `apps/desktop/e2e/support/app-lifecycle.ts` and its test | modify | 7 | `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` | +| Lab images, VM runs, probe scripts (session scratch only) | external | 8 | log lines in `progress.md` | +| `docs/skills/bibcode-profiler/references/events.md`, `docs/testing/**` (living runbooks), `docs/architecture/overview.md`, `docs/architecture/rpc-and-orchestration.md`, `CHANGELOG.md` | modify | 9 | `vp run check:markdown-links` | + +Shared files reserved for the executor: `pnpm-lock.yaml`, `Cargo.lock`, `CHANGELOG.md` (written in Phase 9 only), the docs listed for Phase 9, `progress.md`. +Parallel conflicts: none. Round 1's seven phases own disjoint files. Two points to keep apart: Phase 1 alone edits `apps/server/src/lifecycle.rs` and `production/control.rs`; Phase 6 edits `components/ui/**` and Phase 4 must not. Phases 2, 3 and 7 and the two Rust tasks of Phase 1 are the Rust writers (4 at most at once). + +## Traceability + +| Requirement | Tasks | +|---|---| +| R1 | 1.1 | +| R2 | 1.2 | +| R3 | 1.3 | +| R4 | 1.4 | +| R5 | 1.10, 9.2 | +| R6 to R11 | not planned: withdrawn from the spec, own folder for #350 (D2) | +| R12 | 5.1, 5.2, 8.1, 8.3 | +| R13 | 3.1, 8.3, 8.4 | +| R14 | 7.1 | +| R15 | 7.2 | +| R16 | 1.8 | +| R17 | 1.5 | +| R18 | 5.3 | +| R19 | 8.2 | +| R20 | 8.1 | +| R21 | 2.1 | +| R22 | 2.2 | +| R23 | 2.3 | +| R24 | 9.1 | +| R25 | 9.1 | +| R26 | 2.4, 9.1 | +| R27 | 2.5 | +| R28 | 2.6 | +| R29 | 2.7 | +| R30 | 2.8 | +| R31 | 3.2 | +| R32 | 3.3, 9.1 | +| R33 | 3.4 | +| R34 | 3.5 | +| R35 | 4.2 | +| R36 | 4.3 | +| R37 | 4.1 | +| R38 | 4.5 | +| R39 | 4.4 | +| R40 | 6.1, 6.2 | +| R41 | 1.6, 9.2 | +| R42 | 1.7 | +| R43 | 1.9 | +| R44 | 7.3 | +| R45 | 7.4 | +| R46 | 6.3 | +| R47 | 6.4 | + +## End-to-end verification + +- `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test -p bibcode-server`, `cargo test -p bibcode-desktop`: exit 0 (Task 9.3). +- `vp check`, `vp run typecheck`, `vp run test`, `vp run check:contracts`, `vp run check:markdown-links`: exit 0 (Task 9.3). +- Linux lab: the packaged default list passes with the Profiler on; `chat-reasoning-rows` passes at 1000x720; the R20 numbers are logged (Task 8.1). +- Windows VM: `chat-reasoning-rows` passes; the removal test passes in 12 of 12 whole-suite runs; fmt, Clippy, `vp check` and typecheck exit 0 (Task 8.3). +- Mac: the removal test passes 50 of 50 under load (Task 8.4); the hint test passes 50 of 50 under load or its failure is fixed (Task 7.3). +- Previous release's server: attach, resize and keybindings answer through their legacy paths (Task 8.2). +- Reviews of changed React against `vercel-react-best-practices` and of changed UI against `UI.md` (Task 9.3). + +## Risks + +- `allowProposedApi: true` for the Unicode addon turns on other proposed xterm APIs -> Task 1.10 lists what it enables and the reviewer judges it. +- Unicode 11 widths change how existing scrollback reflows and how wide characters sit at the margin -> Task 1.10's real-xterm test and the packaged terminal specs in Task 8.1. +- The R12 diagnosis may land outside the three suspected paths -> Task 5.1 records the scroll's caller before Task 5.2 chooses a fix; no fix is written before it. +- Rewriting two existing tests that assert the old behavior (R14's volume test, R27's "never stored" tests) could hide a regression -> each task states the old assertion it replaces and why. +- Lab time: the Windows VM's 12 whole-suite runs take hours -> Task 8.3 runs them unattended, one after the other, and nothing else waits on them except Phase 9. +- A decide-by-test item finds a real defect whose fix is larger than its task -> the task's fix branch is written in the phase file; anything beyond it becomes a new task in the same phase, logged as a Ruling. + +## Decisions + +- P1. #350 leaves this plan (spec D2). Alternatives: keep its seven phases here (blocks nine issues on a design that failed review). Decided by: agent's call, to confirm with the user before the push. +- P2. One plan folder for nine issues, one phase per file-ownership area rather than per issue, so that seven lanes can run at once without sharing a file. Alternatives: one phase per issue (three issues share `ThreadTerminalPanel.tsx`). Decided by: agent's call. +- P3. Before round 1 the coordinator runs the packaged `chat-reasoning-rows` spec on the base commit in the Linux lab at 1000x720 and logs the result, so Task 5.1 starts from a known reproduction or a known absence of one. It is a coordinator step, not a task, because every lab task depends on the fixes. Decided by: agent's call. +- P4. Skills were named from the session's installed list by the planner (`tdd`, `vercel-react-best-practices`, `better-interface`, `better-writing`, `mstack-diagnose`, `bibcode-profiler`); `mstack-find-skills` was not run for a wider search. A task runs without a specialist where its Skills line says "none". Decided by: agent's call. +- P5. Probe scripts for Task 8.2 are not committed: a one-off check does not earn a maintained script and its test. Decided by: agent's call. +- P6. The plan review is by Codex (`gpt-6.1-sol`, effort high), and approval follows the standing rule of spec D3. Decided by: user's standing rule. + +## Appendix: questions record + +- 2026-11-13 Q: Which issues, and how big is the batch? / Recommended: all open issues / Decision: answered by the request ("process all open issues ... fix them all in this PR"). +- 2026-11-13 Q: Where does the plan go? / Recommended: `docs/plans/-/`, the layout of the previous batch / Decision: resolved without asking. +- 2026-11-13 Evidence: five read-only verifiers against `main` at `a9938193b` / Decision: every item got a disposition (spec, opening section). +- 2026-11-13 Evidence: Codex review of the #350 design, verdict "needs rework" (`../2026-11-13-terminal-screen-350/reviews/design-review-1.md`) / Decision: P1; R1, R12 and R35 corrected from its findings 11, 12 and 13. +- 2026-11-13 Evidence: `git worktree list` succeeds from a readable repository when only a worktree's volume is missing (`worktree_audit.rs`, the prunable flag) / Decision: D9 uses the parent-directory gate. +- 2026-11-13 Evidence: lookups run only on status publications (`git_vcs.rs`), so nothing refreshes an idle branch / Decision: D7 ties expiry to a failed lookup. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/progress.md b/docs/plans/2026-11-13-issue-batch-348-358/progress.md new file mode 100644 index 000000000..7a051e196 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/progress.md @@ -0,0 +1,61 @@ +# Progress: Issue batch #348 and #351 to #358 + +Plan: plan.md +Spec: spec.md (sha256 2df2631120ee) +Plan status: Draft +Plan review: none yet +Executor: none +Branch: claude/open-issues-pr-dc3c33 + +| Task | Title | Phase | State | Owner | Note | +|---|---|---|---|---|---| +| 1.1 | Repaint after a late font load | 1 | Open | | | +| 1.2 | Repaint a renderer created after host configuration | 1 | Open | | | +| 1.3 | Assert the repaint on a same-size return | 1 | Open | | | +| 1.4 | Development record of size and repaint decisions | 1 | Open | | | +| 1.5 | Say that the server kept the size when Fit sends nothing | 1 | Open | | | +| 1.6 | A dedicated hand-over capability | 1 | Open | | | +| 1.7 | Retry WebGL in a new terminal and say why it failed | 1 | Open | | | +| 1.8 | Decide whether a re-attach sends a transient size (R16) | 1 | Open | | | +| 1.9 | Decide whether the grid is taller than its mount before the first snapshot (R43) | 1 | Open | | | +| 1.10 | Count columns by Unicode 11 in every terminal | 1 | Open | | | +| 2.1 | Record the pull limit at takes_index_lock | 2 | Open | | | +| 2.2 | Record the hook limit at the forced-stop cleanup | 2 | Open | | | +| 2.3 | Anchor the sign-in detector to Git's fatal line | 2 | Open | | | +| 2.4 | Wait for running lookups at quiesce | 2 | Open | | | +| 2.5 | Remember a failed lookup for 5 seconds | 2 | Open | | | +| 2.6 | Stop showing a remembered pull request after a failed lookup on stale data | 2 | Open | | | +| 2.7 | Record the limit at spawn_for_subscribers | 2 | Open | | | +| 2.8 | Decide by test whether a status-neutral local change leaves a stale answer | 2 | Open | | | +| 3.1 | Make the drain test wait on the condition | 3 | Open | | | +| 3.2 | Delete creation records by id | 3 | Open | | | +| 3.3 | Audit both creation-rollback arms | 3 | Open | | | +| 3.4 | Reap a record only when its parent directory exists | 3 | Open | | | +| 3.5 | Record the limits in comments | 3 | Open | | | +| 4.1 | Hold a source registered during the install | 4 | Open | | | +| 4.2 | Restore the block when the window loads during an install | 4 | Open | | | +| 4.3 | Give the installing dialog a way out | 4 | Open | | | +| 4.4 | Decide whether the dialog hides the Manage devices toast | 4 | Open | | | +| 4.5 | Fail when a desktop event name exists on one side only | 4 | Open | | | +| 5.1 | Diagnose which path scrolls to the end after a programmatic scroll and a toggle | 5 | Open | | | +| 5.2 | Fix the first wrong decision the diagnosis names | 5 | Open | | | +| 5.3 | Stop the Host dashboard values view re-rendering on the clock tick | 5 | Open | | | +| 6.1 | Occlusion leases that carry a rectangle | 6 | Open | | | +| 6.2 | Popups hold a rectangle lease only while open | 6 | Open | | | +| 6.3 | The model picker reopens with an empty search | 6 | Open | | | +| 6.4 | Read-only Markdown loads relative images from the workspace | 6 | Open | | | +| 7.1 | Hide any read-only FUSE mount under a `.mount_` directory | 7 | Open | | | +| 7.2 | Check for a dead webview before every test and keep the relaunch inside the test allowance | 7 | Open | | | +| 7.3 | Run the flaky hint test 50 times under CPU load and diagnose any failure | 7 | Open | | | +| 7.4 | Log a rejected pairing offer and a rejected pairing credential at `warn` with a fixed class | 7 | Open | | | +| 8.1 | Linux lab run of the packaged list, with the two discriminating runs | 8 | Open | | | +| 8.2 | Mixed-version probe against the previous release's server | 8 | Open | | | +| 8.3 | Windows VM runs | 8 | Open | | | +| 8.4 | Load runs on the Mac | 8 | Open | | | +| 9.1 | Profiler and validation references | 9 | Open | | | +| 9.2 | Architecture docs, runbooks and changelog | 9 | Open | | | +| 9.3 | Final gates and reviews | 9 | Open | | | +| 9.4 | Reviews before the push, then the pull request | 9 | Open | | | + +## Log +- 2026-11-13 Plan written. Sources: ten issues exported to `sources/`, `evidence.md`, `triage.md`. #350 moved to `../2026-11-13-terminal-screen-350/` after its design review returned "needs rework". diff --git a/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md b/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md new file mode 100644 index 000000000..affc20f01 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md @@ -0,0 +1,103 @@ +Verdict: **Needs rework.** All 41 active requirements have task assignments, and the main ownership boundaries are sensible. However, the plan contradicts R35, misses lookup lifecycle paths, and includes diagnostic tests that could produce false conclusions or remain red after their prescribed fixes. + +Coverage: **requirement → tasks** + +“Partial” means a scenario, caller, or necessary proof is missing—not that the requirement has no assignment. Mapping source: `docs/plans/2026-11-13-issue-batch-348-358/plan.md:108`. + +| Requirement | Tasks | Assessment | +|---|---|---| +| R1 | 1.1 | Covered: owner, mirror, legacy, unmount | +| R2 | 1.2 | Covered: delayed creation and disposed renderer | +| R3 | 1.3 | Covered | +| R4 | 1.4 | Assigned; verification filter needs correction | +| R5 | 1.10, 9.2 | Covered; server width parity explicitly outside scope | +| R6–R11 | — | Withdrawn; no gap | +| R12 | 5.1, 5.2, 8.1, 8.3 | **Partial:** flawed diagnostics; independent viewport variation unassigned | +| R13 | 3.1, 8.3, 8.4 | Covered: injected delay, 50 loaded runs, 12 Windows suite runs | +| R14 | 7.1 | Covered, including intentional old-test changes | +| R15 | 7.2 | **Partial:** timeout arithmetic does not bound the complete recovery | +| R16 | 1.8 | Covered by conditional test/fix | +| R17 | 1.5 | Covered | +| R18 | 5.3 | Covered | +| R19 | 8.2 | Assigned; prescribed command fails | +| R20 | 8.1 | Assigned bounded probe | +| R21 | 2.1 | Covered by recorded limit | +| R22 | 2.2 | Covered by recorded limit and existing test | +| R23 | 2.3 | Covered, including both caller inputs and carriage returns | +| R24 | 9.1 | Covered | +| R25 | 9.1 | Covered | +| R26 | 2.4, 9.1 | **Partial:** shutdown misses flights outside cache entries | +| R27 | 2.5 | Assigned; cancellation/time-control proof needs correction | +| R28 | 2.6 | **Partial:** late refresh failures omitted | +| R29 | 2.7 | Covered by recorded limit | +| R30 | 2.8 | Covered by conditional test/fix | +| R31 | 3.2 | Assigned; prescribed mutation does not prove the regression | +| R32 | 3.3, 9.1 | **Partial:** audit assertions do not cover both rollback callers | +| R33 | 3.4 | Covered: missing parent, existing parent, parent disappearing | +| R34 | 3.5 | Covered by recorded limits | +| R35 | 4.2 | **Conflict:** first failed reload query permits saving | +| R36 | 4.3 | Covered | +| R37 | 4.1 | Covered | +| R38 | 4.5 | Assigned; scanner produces a false mismatch | +| R39 | 4.4 | **Partial:** defect branch names the wrong host mounting location | +| R40 | 6.1, 6.2 | **Partial:** position-only popup movement omitted | +| R41 | 1.6, 9.2 | **Partial:** another hand-over decision bypasses the capability | +| R42 | 1.7 | Covered | +| R43 | 1.9 | Covered by conditional test/fix | +| R44 | 7.3 | Covered by loaded runs and conditional diagnosis | +| R45 | 7.4 | Assigned; promised in-memory rejection classification is unavailable | +| R46 | 6.3 | **Partial:** controlled openings bypass the proposed reset | +| R47 | 6.4 | Assigned; existing test mocks prevent the prescribed caller proof | + +No active requirement is wholly unassigned. No task is entirely unrelated: Tasks 9.3 and 9.4 support the spec’s verification and delivery requirements. + +**Findings, most severe first** + +Phase-file locations below are relative to `docs/plans/2026-11-13-issue-batch-348-358/`. + +| # | Severity | Location (file:line) | Issue | Evidence | Fix | +|---|---|---|---|---|---| +| 1 | Major | `phases/PHASE-04-update-install-guard.md:44` | Task 4.2 contradicts R35 during the dangerous reload interval. | `spec.md:206` requires saving to stay held after the first query rejects. Task 4.2 explicitly holds nothing before an answer and tests that nothing is blocked at `PHASE-04:54`. | Preserve evidence that this window allowed an install across reload, and retain the hold until a confirmed non-running state. Test edits/autosave during the first rejection and timeout. | +| 2 | Major | `phases/PHASE-02-git-and-pull-request-lookups.md:104` | Tracking every flight does not cancel every flight. | The implementation stores cancellation tokens only in `Slot::InFlight`. Invalidation removes those entries (`apps/server/src/source_control/open_pull_request_lookups.rs:170`), and capacity overflow starts flights without inserting entries (`:206`). Shutdown can therefore wait on a flight whose cancellation it cannot reach. Also, the existing result forwarding returns the loader’s error, not the planned waiter’s `None` (`:159`, `:224`). | Give every flight a shutdown cancellation ancestor independent of cache membership. Define cancelled completion explicitly. Add shutdown tests for an invalidated flight, capacity overflow, and a retained waiter. | +| 3 | Major | `phases/PHASE-02-git-and-pull-request-lookups.md:164` | Task 2.6 misses failures arriving after the refresh timeout. | The late callback handles only successful lookup answers (`apps/server/src/production/git_vcs.rs:949`). An existing test explicitly exercises a refresh whose lookup runs without another stream lookup (`:9093`). The plan changes the immediate unary and stream completion sites only. | Handle uncancelled failure in the late callback too. Add a stale-request test where that late lookup fails and the subscriber clears the request. | +| 4 | Major | `phases/PHASE-05-reasoning-row-scroll-and-dashboard.md:44` | H3 can “confirm” a path that is already disabled. | The test equates `followsEnd === true` with enabled LegendList maintenance. The actual predicate returns `false` whenever anchored, regardless of `followsEnd` (`apps/web/src/components/chat/MessagesTimeline.logic.ts:43`); the component supplies both inputs (`MessagesTimeline.tsx:754`). | Diagnose the actual `maintainScrollAtEnd` value and resulting list behavior. A boolean captured from a mocked child is insufficient attribution. | +| 5 | Major | `phases/PHASE-05-reasoning-row-scroll-and-dashboard.md:43` | The H2 failing test cannot be fixed by the prescribed H2 change. | H2 deliberately never calls `onReasoningRowToggle`. Its proposed fix depends on a row-ID ref set by that callback (`PHASE-05:55`). Furthermore, row-ID plumbing is allocated only to H1, while existing callback contracts take no arguments (`apps/web/src/components/chat/MessagesTimeline.tsx:199`, `:311`). | Separate a growth-path diagnostic from a toggle regression. Exercise the real toggle before testing the guard; include callback types/caller changes for H2 too. Update fixture rows and calls while preserving the #173 assertion—its current fixture contains anonymous rows and invokes the callback without an ID (`ChatView.test.tsx:5543`, `:5592`). | +| 6 | Major | `phases/PHASE-01-terminal-panel-fixes.md:161` | Replacing only the `resized` assignment does not fully gate hand-over behavior. | `applySize` independently sets `unownedUntilUsed` through `isUnownedUntilUsed(size)` (`apps/web/src/components/ThreadTerminalPanel.tsx:1904`). That helper checks `replyClaim`, without consulting capabilities (`terminalSizePolicy.ts:60`). | Gate every entry into the hand-over state with `sizeHandOver`. Include snapshots/attachments carrying `replyClaim` with the capability absent or false. | +| 7 | Major | `phases/PHASE-06-popups-picker-and-markdown-images.md:65` | The picker reset misses the composer’s controlled keyboard openings. | The proposed counter increments only in `setIsMenuOpen`. Controlled state comes directly from `props.open` (`apps/web/src/components/chat/ProviderModelPicker.tsx:44`), and the composer opens it directly through its imperative handle (`ChatComposer.tsx:2250`) while passing controlled `open` (`:2911`). | Reset on the actual closed-to-open transition, including external prop changes. Test typing, closing, then reopening through the composer’s controlled path. | +| 8 | Major | `phases/PHASE-06-popups-picker-and-markdown-images.md:43` | Popup leases become stale when a popup moves without resizing. | The proposed geometry updates observe size and window resize only. A positioner can move through anchor scrolling or positioning updates while keeping its dimensions; the existing popover explicitly transitions positional properties (`apps/web/src/components/ui/popover.tsx:44`). | Subscribe to positioning/scroll changes or an equivalent geometry signal. Test movement into and out of overlap without a size change. | +| 9 | Major | `phases/PHASE-07-server-odds-and-e2e-harness.md:47` | The claimed recovery worst case excludes potentially long operations. | Recovery includes the initial WebDriver probe, `reloadSession`, and mock restoration (`apps/desktop/e2e/support/app-lifecycle.ts:272`, `:279`, `:348`). WDIO permits a command to wait 120 seconds (`wdio.conf.ts:165`), already exceeding the proposed 20-second margin. The test merely sums constants. | Enforce one deadline across the complete recovery and test delayed operations. Add a test of the `beforeTest` registration; calling the existing recovery helper twice does not detect an omitted hook. | +| 10 | Major | `phases/PHASE-07-server-odds-and-e2e-harness.md:95` | The claimed in-memory `revoked` classification cannot be recovered through the prescribed flow. | Revocation removes the in-memory pairing (`apps/server/src/auth/service.rs:2088`). A later credential lookup therefore sees no record (`:3088`), so the test at `PHASE-07:106` cannot distinguish revoked from unknown. | Promise only classifications available in each storage mode. Reporting `unknown` for removed in-memory records satisfies R45 without introducing retention solely for logging. | +| 11 | Major | `phases/PHASE-06-popups-picker-and-markdown-images.md:74` | Two prescribed tests cannot reach the behavior they claim to prove. | The picker test replaces `ModelPickerContent` with a component containing no search input (`apps/web/src/components/chat/ProviderModelPicker.test.tsx:94`). The Files test replaces `ChatMarkdown` with a text-only div (`files/FilePreviewPanel.test.tsx:368`), preventing any image from reaching the loader required at `PHASE-06:96`. | Explicitly change the harnesses or add integration tests using the real components. Keep prop-forwarding assertions separate from actual search/image behavior. | +| 12 | Major | `phases/PHASE-03-worktree-records-and-removal-test.md:81` | R32 lacks audit proof for the record-write rollback, and the new profiler test risks global-state interference. | The audit assertion exercises dispatch failure only. The integration file expressly keeps all scenarios in one test because the profiler is process-global (`apps/server/tests/profiler_worktree_audit.rs:1`). | Extend that existing test with both rollback arms and relevant outcome assertions. Each production audit call must have a test that fails when that call alone is removed. | +| 13 | Minor | `phases/PHASE-04-update-install-guard.md:101` | The toast defect branch routes its callback through the wrong mounting location. | `AppRoot` mounts the notifier (`apps/web/src/AppRoot.tsx:170`), but the close host is mounted in the root route, including several gate states (`apps/web/src/routes/__root.tsx:194`, `:205`, `:229`, `:316`). Those files are absent from Task 4.4’s edit set. | Name and own the actual host callers and routing tests in the conditional branch. | +| 14 | Minor | `phases/PHASE-08-labs-and-probes.md:51` | The mixed-version probe stops at its first command. | Running the exact command reports “Missing required flag: --channel.” Both channel and current tag are required (`scripts/resolve-previous-release-tag.ts:362`, `:365`); output is `previous_tag=…` (`:332`). | Supply concrete flag derivation and parse the output. Specify the release asset, isolated startup/authentication, and exact runtime calls needed by the scratch probe. | +| 15 | Minor | `phases/PHASE-02-git-and-pull-request-lookups.md:130` | Backoff proof lacks necessary time and settlement control. | The rewritten existing test uses `advance`, but currently has an unpaused `#[tokio::test]` (`apps/server/src/source_control/open_pull_request_lookups.rs:435`). The cancellation test also does not require the old flight to settle before retry; a new admission can replace its identity (`:196`), hiding a broken cancellation-storage rule. | Explicitly pause the existing test and wait for cancelled-flight settlement before resolving again. | +| 16 | Minor | `phases/PHASE-03-worktree-records-and-removal-test.md:60` | The proposed SQL mutation does not make the recreation test fail. | `INSERT OR REPLACE` removes the old record (`apps/server/src/persistence/repositories.rs:321`). The mutant’s subquery for that old ID consequently returns no path and deletes nothing. An in-memory SQLite probe confirmed the replacement survives. | Mutate the removal caller back to deleting its captured path, then demonstrate that the handler test deletes the replacement record. | +| 17 | Minor | `phases/PHASE-02-git-and-pull-request-lookups.md:102` | Ignoring TERM does not inject delayed forge cleanup through this runner. | Forge commands use the process runner (`apps/server/src/source_control/pull_request.rs:1465`), whose non-Git stop policy is `Force` (`apps/server/src/git/process.rs:946`). | Inject delayed completion/cleanup through a runner seam or synchronization gate. Do not rely on a TERM trap to establish the 200 ms shutdown condition. | +| 18 | Minor | `phases/PHASE-01-terminal-panel-fixes.md:113` | Several verification recipes cannot produce their stated result. | The diagnostics filter excludes the three ring-test titles at `PHASE-01:118`. The shutdown prefix at `PHASE-02:93` selects only two of its three named tests. The File map command at `plan.md:84` is rejected by Cargo for its second positional filter; this was reproduced without running tests. | Correct selectors, use `--` for multiple libtest filters, and make working directories explicit. | +| 19 | Minor | `phases/PHASE-09-docs-gates-and-pull-request.md:47` | Documentation tasks leave directly contradicted living statements behind. | The overview says failures are never reused (`docs/architecture/overview.md:177`) and gates hand-over on `terminalUserInputClaim` (`:1576`). The shared testing runbook describes worker-only recovery and cascading failures (`docs/testing/cross-platform-validation.md:667`). The planned edits do not name these updates. | Explicitly update lookup backoff/expiry, the overview’s capability rule, and shared harness documentation. | +| 20 | Minor | `phases/PHASE-05-reasoning-row-scroll-and-dashboard.md:40` | R12’s independent viewport experiment has no task. | `spec.md:104` requires varying viewport independently. Phase 5 and Task 8.1 schedule only the 1000×720 run. Shared setup also sets that size itself (`apps/desktop/e2e/support/ui-state.ts:70`). | Add a second measured viewport run and a way to apply it after shared setup. Record actual CSS dimensions. | +| 21 | Minor | `phases/PHASE-02-git-and-pull-request-lookups.md:143` | The named broadcaster type does not exist. | The existing type is `StatusBroadcaster` (`apps/server/src/git/broadcaster.rs:35`), not `GitBroadcaster`. | Correct the Interfaces names, including the proposed test setter. | +| 22 | Minor | `phases/PHASE-09-docs-gates-and-pull-request.md:70` | The final task substitutes weaker checks for the binding whole-tree gate. | The spec requires successful `vp check` (`spec.md:251`); the task runs whole-tree `vp check --no-fmt` plus formatting checks on changed files. | Run the required command, or explicitly resolve the spec discrepancy before claiming completion. | + +Dependencies and parallelism: I found no direct round-one source-file collision in the declared ownership sets. The contracts fixtures and web manifest belong to Phase 1, popup primitives to Phase 6, and lockfile writes to the executor (`plan.md:81`, `:82`, `:94`, `:103`). Preserve separate lane build directories. The important missing dependency is inside Task 5.2: H2 needs the row-ID plumbing currently assigned only to H1. + +Task-size judgment: comment-only Tasks 2.1 and 2.2 are too small to warrant separate reviews of the same file; Task 1.3 can accompany the related repaint tests. Conversely, Task 8.3 should checkpoint its build, static gates, packaged result, and each completed suite run so its hours-long measurement can resume independently (`PHASE-08:57`). The named skills generally fit the work; the substantive problems are the recipes and caller coverage. + +Consistency: all **47 task IDs and titles** match `progress.md`, and the spec hash matches `2df2631120ee`. Phase 9’s checkpoint nevertheless refers to Task 9.2’s commands where it appears to mean 9.3 (`PHASE-09:10`). + +**What the plan got right** + +- Task 1.1 preserves mirror geometry by proposing dimensions without fitting locally, and its resizing fit mock can catch accidental reflow (`PHASE-01:23`, `:36`). +- Task 1.10 correctly accounts for `allowProposedApi`, real-xterm width assertions, and the existing fake addon lifecycle. Server width parity remains explicitly outside this plan (`PHASE-01:275`, `:290`; `spec.md:96`). +- The proposed quiesce position and obtaining the final built service instance are sound choices (`PHASE-02:87`, `:105`). +- Rewriting the old failure-cache and AppImage assertions is intentional and justified by the binding spec (`PHASE-02:130`; `PHASE-07:14`). +- Capturing creation IDs and checking the parent directory again at reap time preserve the intended race and unmounted-volume protections (`PHASE-03:45`, `:93`). + +**Open questions the plan cannot answer** + +- What surviving signal identifies a reload during an allowed install, so R35 can hold saving while its first query fails? +- What reproducible recipe creates the named Linux lab image when absent? The cited workflow provides native-runner setup, not that image’s construction (`PHASE-08:21`; `.github/workflows/desktop-ui-smoke.yml:18`). +- What exact automated scenario kills a webview and proves the next test recovers? Phase 7 promises that later evidence, but Phase 8 does not schedule the injection (`PHASE-07:10`; `PHASE-08:30`). + +No files changed. Validation was limited to source inspection, in-memory probes, task/hash comparison, and the two failing command-parser checks described above; no builds, suites, or native runs were performed. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-348.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-348.md new file mode 100644 index 000000000..f5117e70c --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-348.md @@ -0,0 +1,133 @@ +# #348 AI Terminal: rendering breaks intermittently until a panel is resized + +## Problem + +(measured: reporter statement, not an independent reproduction) The repository owner reports intermittent rendering problems in AI terminals that disappear after resizing the right panel, for example, with WebGL both enabled and disabled. **The exact visual symptom is unknown**: garbled text, blank output, misaligned/stale rows, clipping, or wrong dimensions have not been established. A screenshot or short recording from the reporter, ideally showing the failure before and after the resize, would help distinguish them. + +(measured) This is a static investigation of `origin/main` at **`3d3a08efd0dd054c1c7a205d87dc875fe28171a7`**. All source references below are repo-relative `path:line` references at that full SHA, not the reporter's verified running version. No app, server, tests, production logs, databases, or profiler data were used. + +(inferred) Status: triage issue with a proposed behavioral specification, prepared using **mstack-write-specs**, adapted to ISSUE form. The cause is not confirmed. `(measured)` denotes inspected source/search results or explicitly attributed reporter evidence; `(inferred)` denotes a deduction or proposed requirement; `(guess)` denotes an unverified possible trigger/cause. + +## Expected vs Actual + +- (inferred) Expected: an AI terminal presents current output at the appropriate dimensions and repaints after layout, visibility, font, or display-scale changes without an unrelated manual panel resize. Existing multi-window size ownership remains authoritative. +- (measured: reporter statement) Actual: intermittent rendering trouble is corrected by a right-panel resize; it occurs with WebGL on and off. Frequency, exact appearance, and a deterministic reproduction are unknown. +- (inferred) Goal/user scenario: an AI-terminal user can switch panes and continue reading and interacting with the same session without repairing its display by dragging another panel. + +## Steps to reproduce — candidates, not a verified recipe + +(inferred) In an isolated fixture session, open an AI terminal with deterministic multiline output and a full-screen TUI fixture. Compare WebGL enabled and disabled, and a plain terminal using the same fixture. Try these candidate triggers in rank order; capture the first failure before resizing anything: + +1. (guess) Open or return to an AI-terminal tab while host configuration is still arriving; also switch tabs/threads or hide and restore the document. This exercises delayed renderer creation and replay. +2. (guess) Open/close or maximize/restore the right panel, open/close the terminal's Activity panel, and change center splits. Include a temporary zero-width/zero-height host and restoration to its previous dimensions. +3. (guess) Change terminal font or delay font availability; change zoom/DPR while the terminal's CSS box stays the same size. These can change drawing metrics without an ordinary container-size notification. +4. (guess) Reattach/replay a live session while layout changes, and delay a resize response or fail a resize in the fixture. Include a mirror window without stealing its owner's size. +5. (inferred) If rendering becomes wrong, record the visual symptom and geometry first, then resize the right panel slightly and record the result. Distinguish a repaint-only recovery from one that changes columns/rows and makes the TUI redraw. No candidate above is asserted to reproduce the owner's report. + +## Static trace and likely cause + +### Shared path and what the workaround triggers + +- (measured) AI/command terminals in the center use `CenterTerminalPanel` → `ThreadTerminalPanel` → `TerminalViewport`; the command is passed through the center surface (`apps/web/src/components/CenterTerminalPanel.tsx:52`, `:72`, `:89`). Plain right-panel terminals use `PersistentThreadTerminalPanel` → the same `ThreadTerminalPanel` (`apps/web/src/components/ChatView.tsx:900`). Both split and single viewport callers pass `visible` (`apps/web/src/components/ThreadTerminalPanel.tsx:2916`, `:2947`). The center wrapper also accepts terminals without a command, so this is not a separate AI-only renderer. +- (measured) `TerminalViewport` constructs xterm with FitAddon, opens it, and calls `fitTerminalSafely`; the helper catches fit exceptions and returns a boolean that these sizing callers do not check (`apps/web/src/components/ThreadTerminalPanel.tsx:561`, `:1257`, `:1318`, `:1421`). The manifest declares xterm `^6.0.0`, fit `^0.11.0`, and optional WebGL `^0.19.0` (`apps/web/package.json:36`). No separate canvas addon is declared there. (inferred) With WebGL off/failed, the path uses xterm's default non-WebGL renderer; this trace does not establish the exact underlying renderer implementation from dependency source. +- (measured) Right-panel dragging updates width once per animation frame and commits the pending final width on pointer-up (`apps/web/src/hooks/useResizableWidth.ts:150`, `:182`). `PreviewPanelShell` applies that width to the inline panel (`apps/web/src/components/preview/PreviewPanelShell.tsx:30`, `:48`); the adjacent center column is flex-sized (`apps/web/src/components/ChatView.tsx:7327`). Center body-target observers watch the workspace root and body targets and synchronize the overlay hosts (`apps/web/src/components/CenterPanelSurfaceHosts.tsx:201`, `:249`). +- (measured) Each rendered terminal observes its actual xterm mount div, `data-terminal-xterm-mount`, not just the window (`apps/web/src/components/ThreadTerminalPanel.tsx:2312`, `:2352`, `:2392`). Its callback cancels earlier pending frame/timer work, runs `size("layout")` in a frame, then clears the texture atlas and calls `refresh(0, rows - 1)` after 50 ms (`:2318–2349`). (inferred) A right-panel width change can therefore produce the extra measurement/size reconciliation **and full repaint** that clears the reported fault; source inspection alone cannot identify which operation actually repairs it. +- (measured) Without size ownership, `size("layout")` calls fit and sends terminal columns/rows, preserving bottom-following (`apps/web/src/components/ThreadTerminalPanel.tsx:1415–1427`). With ownership, it proposes dimensions and sends a claim only when policy permits; xterm is resized from the ordered server size event (`:1429–1501`, `apps/web/src/components/terminalSizePolicy.ts:44`). Matching owner dimensions suppress the RPC; mirrors deliberately retain the owner's geometry. (inferred) Do not implement an unconditional local fit for mirrors or send a redundant PTY resize just to repaint. +- (measured) The RPC chain is `terminalEnvironment.resize` → `WS_METHODS.terminalResize` (`packages/client-runtime/src/state/terminal.ts:282`) → `terminal.resize` handler (`apps/server/src/production/server_terminal.rs:958`) → manager dimension validation, process resize, and ordered `Resized` publication (`apps/server/src/terminal/manager.rs:3596–3663`) → PTY resize worker (`apps/server/src/terminal/pty.rs:479`, `:1100`). The manager publishes after enqueueing; the worker applies the native resize asynchronously and warns if it fails. (inferred) An RPC success/size echo alone does not prove the native PTY accepted the new dimensions. + +### Ranked hypotheses + +1. **(inferred) Lifecycle recovery does not consistently include the repaint/font-completion work present on container resize.** AI commands wait for authoritative host configuration before creating a renderer (`apps/web/src/components/ThreadTerminalPanel.tsx:992–995`, `:1246–1248`). The font effect can run while no terminal exists and return; it depends only on `shouldRender` and `terminalFontFamily`, not renderer epoch/config readiness (`:2064–2091`). The observer effect also depends only on `shouldRender`, and an early notification returns if no terminal exists (`:2328–2330`, `:2367`). The epoch effect later requests sizing but no explicit refresh (`:2369–2386`); the 30 ms mount timer also only requests sizing (`:1964–1969`). **(guess)** If initial observer delivery precedes delayed creation, font metrics or paint could remain stale until a real box change supplies the missing trailing repaint. Existing initial fit, replay writes, and xterm's own repaint behavior may already recover many cases; this is a testable gap, not proof of the report's cause. +2. **(inferred) Logical visibility can differ from measurable geometry.** Center hosts accept zero dimensions (`apps/web/src/components/CenterPanelSurfaceHosts.tsx:94–111`); visibility requires an active surface and an existing rect, not a positive rect (`:292–304`). `ChatView` passes center focus eligibility but not `context.visible` to `CenterTerminalPanel` (`apps/web/src/components/ChatView.tsx:1001–1013`); right-panel maximize makes the center column width zero (`:7327–7332`). Dimension proposal clamps finite values and returns null for unavailable/error cases without scheduling a retry (`apps/web/src/components/terminalSizing.ts:11–24`). **(guess)** A transient hidden/zero box or deferred layout can therefore leave a renderer waiting for another layout event or measured size; test zero→positive recovery and restoration to the same old dimensions. This is not evidence that ResizeObserver necessarily misses such transitions. +3. **(inferred) Font/DPR invalidation has narrower application coverage than general layout.** The font helper explicitly loads the bundled font once through `document.fonts.load`, including a caught failure, while the viewport only reruns its font effect on visibility/font-family changes (`apps/web/src/lib/terminalFont.ts:52–64`; viewport `:2064–2091`). There is no general `document.fonts.ready`/`loadingdone` subscription or explicit DPR/zoom subscription in the inspected terminal helpers/viewport. The WebGL fallback correction samples `devicePixelRatio` only when its canvas ResizeObserver fires (`apps/web/src/components/terminalDevicePixelCorrection.ts:40–60`). **(guess)** A late selected-font load or DPR-only change at unchanged CSS dimensions could need explicit invalidation. This is an application-level gap to test; xterm/browser internals may cover part of it. +4. **(guess) Replayed TUI state or unsuccessful PTY resize can look like a paint problem and recover when a real size change makes the program redraw.** Reset handling applies ordered size state and replays the bounded snapshot (`apps/web/src/components/ThreadTerminalPanel.tsx:1493–1529`). Replay sends RIS and history; only exited sessions receive the mode-reset suffix (`apps/web/src/components/terminalReplyGuard.ts:43–53`). There is no observed corrupt snapshot or lost native resize in this investigation. This hypothesis rises only if buffer/PTY geometry evidence disagrees with the displayed surface; a repaint cannot reconstruct missing terminal output. + +### Existing safeguards and limits of the trace + +- (measured) Pane/document hiding tears down xterm, detaches replay, and disconnects the mount observer; showing it again recreates those resources (`apps/web/src/components/ThreadTerminalPanel.tsx:962`, `:1229`, `:1971–2056`, `:2357–2367`). Inactive non-host center surfaces are unmounted, rather than being kept as `display:none` terminals (`apps/web/src/components/CenterPanelSurfaceHosts.tsx:292–295`). (inferred) The zero-size concern is distinct from the explicit `shouldRender=false` path; there is no basis to claim every tab switch omits fitting. +- (measured) The terminal mount observer has no explicit zero-size check. The WebGL canvas correction skips nonpositive size but leaves its observer connected; it has no independent retry (`apps/web/src/components/terminalDevicePixelCorrection.ts:47–64`). (inferred) A later canvas-size notification can recover it; “ignored forever” is not established. +- (measured) Center dragging intentionally suppresses fitting/repainting while `data-resizing` is set. Release/cancellation flushes host rectangles, clears that flag, and dispatches `bibcode:center-panel-resize-end`, which the terminal listens to (`apps/web/src/components/CenterPanelWorkspace.tsx:419–433`; viewport `:2318–2355`). Pending frame/timer work is cancelled on cleanup and guarded against stale renderers. (inferred) Interrupted/suppressed trailing work merits coverage, but normal drag-end recovery already exists. +- (measured) Layout changes arriving during a pending ownership claim are reconsidered when that claim completes (`apps/web/src/components/ThreadTerminalPanel.tsx:1441–1471`). The client scheduler retains the latest pending resize and drains it (`packages/client-runtime/src/state/runtime.ts:228–269`); the PTY queue also coalesces to the final pending size (`apps/server/src/terminal/pty.rs:355–367`). (inferred) A generic “debounce drops the last resize” diagnosis contradicts these safeguards; failures and lifecycle cancellation need evidence. +- (measured) Theme changes update xterm options and refresh when the resolved theme changes, including an HTML class/style observer (`apps/web/src/components/ThreadTerminalPanel.tsx:634–643`, `:1951–1962`, `:2058–2062`). (inferred) Theme repainting is already implemented; no theme-specific reproduction was reported. +- (inferred) **Renderer independence:** the report with WebGL disabled makes WebGL context loss or texture-atlas corruption less likely as the sole cause. (measured) WebGL has its own lazy attachment, context-loss disposal, generation guards, and device-pixel backing-store correction (`apps/web/src/components/ThreadTerminalPanel.tsx:2093–2189`). (inferred) Any common lifecycle recovery fix must also preserve that WebGL handling and verify repaint after fallback; a WebGL-only fix would not account for the full report. + +## Proposed fix behavior and decisions + +- (inferred; agent's call) Reuse the shared terminal lifecycle to reconcile measurable geometry and repaint after layout, visibility, renderer creation/reattachment, font availability, and DPR changes. Coalesce events, but retain the final pending recovery. Prefer this over new per-panel workarounds because both hosts use the same viewport. Exact event wiring and whether an existing scheduling helper suffices are the implementer's call after reproducing a failing scenario. +- (inferred; agent's call) Treat local repaint and PTY size ownership as separate responsibilities. An owner fits its available area; a mirror repaints at the authoritative size and retains the existing “Sized for another window” behavior. Deduplicate unchanged PTY dimensions, never deduplicate a needed repaint solely because columns/rows match. +- (inferred; agent's call) Defer work for genuinely unmeasurable containers and guarantee recovery on the next valid layout/activation/renderer-ready event; avoid polling or a renderer restart as the default fix. Reuse existing generation/cancellation protection and preserve scrollback, selection where supported, input ordering, and live TUI modes. +- (inferred; agent's call) **size:M**: the expected fix is in shared frontend lifecycle/sizing with both host callers and lifecycle/visual regression coverage. It exceeds a one-line change but does not currently justify a protocol redesign. If evidence identifies native PTY failure or transcript corruption, reassess scope before implementing that separate cause. + +## Acceptance scenarios / requirements + +### R1. Settle geometry and paint [Must] + +(inferred; source: reporter workaround and shared resize trace) WHEN a rendered terminal's layout, visibility, font availability, theme, or display scale changes, the terminal SHALL show its current output at the dimensions permitted by existing size ownership after that transition settles, without requiring a subsequent manual resize. + +- (inferred) **Given** an owner terminal with a known grid/TUI fixture and WebGL on or off, **When** the right panel or Activity panel opens/closes, center splits change, or a tab/document becomes visible, **Then** row/column boundaries, cursor placement, and the fixture's current frame match the settled area without clipped/stale residual rows or an extra drag. +- (inferred) **Given** a terminal whose CSS dimensions remain constant, **When** its selected font becomes available, theme changes, or zoom/DPR changes, **Then** the rendered fixture has the current glyph metrics/colors and aligned drawing surface; unchanged columns/rows do not prevent a repaint. +- (inferred) **Given** a mirror owned by another window, **When** its local layout changes, **Then** it repaints at the owner's dimensions and does not steal ownership or alter the PTY size merely to repair paint. + +### R2. Recover from unmeasurable or delayed creation [Must] + +(inferred; source: hypotheses 1–2) IF a terminal is hidden, has a nonpositive measurement, or has not created its renderer yet, THEN the terminal SHALL defer geometry-dependent recovery until it has a measurable active renderer and complete that recovery without a further user resize. + +- (inferred) **Given** an AI terminal waiting for host config with an early observer callback, **When** config arrives and a delayed font finishes loading without a container-size change, **Then** the first displayed fixture is correctly measured and painted. +- (inferred) **Given** a zero-size/hidden host, **When** it returns to positive size, including the same dimensions as before hiding, **Then** the fixture renders correctly; hidden/invalid geometry does not send a fabricated minimum or invalid size to the PTY. +- (inferred) **Given** a disposed renderer with pending font/frame/timer callbacks, **When** those callbacks settle, **Then** they neither repaint the old renderer nor resize a replacement session. + +### R3. Preserve the final resize and session state [Must] + +(inferred; source: existing resize/replay safeguards) WHEN a resize burst or reattachment finishes, the terminal SHALL converge to the latest valid dimensions permitted by size ownership while retaining the session's input/output order and live terminal modes. + +- (inferred) **Given** a slow size claim or resize RPC and changing layout, **When** the final drag ends or is cancelled, **Then** the last valid settled dimensions reach the fixture PTY and its rendered view, with no repeated idle resize requests. +- (inferred) **Given** a known resize failure followed by a valid recovery event, **When** recovery runs, **Then** the final size can be retried; an older completion does not override newer geometry. +- (inferred) **Given** a live session reattaching with interleaved output/size events, **When** replay completes, **Then** its fixture frame and subsequent output remain ordered, with no replay bytes injected as input and no exited-session mode reset applied to a live TUI. + +### R4. Capture the next failure safely [Should] + +(inferred; agent's call, source: unknown visual symptom) WHEN development rendering diagnostics are explicitly enabled, the terminal SHALL expose bounded content-free evidence that distinguishes geometry, repaint, and PTY-size failures. + +- (inferred) **Given** an isolated synthetic session, **When** the failure occurs and the panel is then resized, **Then** a before/after record correlates the last layout reason, renderer generation, container rect, proposed/applied columns/rows, ownership status, font readiness, DPR, renderer kind, size-request outcome, and repaint scheduling/completion. +- (inferred) **Given** diagnostics disabled or a real session containing private output, **When** normal terminal activity occurs, **Then** no terminal bytes, prompts, paths, credentials, or session-identifying values are captured by this diagnostic facility. + +## Tests and diagnostics to add + +- (measured) Existing interaction tests cover element-only resize and the 50 ms repaint (`apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx:2474`, `:2492`), failed-resize retry/stale completion (`:2545`, `:2562`), hidden callbacks and remounts (`:2587`, `:2610`), center-drag deferral (`:1779`), font changes with both renderers (`:1725`), replay reattachment retaining WebGL (`:2758`), and WebGL fallback/DPR=2 correction (`:3302`, `:3341`). The delayed-host-config test asserts attach behavior, not post-creation font recovery or pixels (`:3119–3142`). +- (measured) Font helper tests cover one shared load and load failure (`apps/web/src/lib/terminalFont.test.ts:58`, `:74`); canvas correction tests cover exact sizing, no-op behavior, and hidden canvas (`apps/web/src/components/terminalDevicePixelCorrection.test.ts:66`, `:109`, `:127`). Desktop font selection exists at `apps/desktop/e2e/specs/terminal-font.e2e.ts:87`. +- (inferred) Gap: these mocked sizing/renderer assertions do not establish the unknown intermittent visual symptom. Add R1–R3 regressions for delayed config + early observer delivery, deferred font completion, zero→positive geometry, same-size activation, DPR-only changes, and cancellation of the trailing repaint. Exercise the composed center/right-panel host path, not only a direct viewport mount. Use a real xterm fixture and deterministic screenshot/grid/cursor assertions as well as mocked scheduling tests. +- (inferred; agent's call) Validate owner/mirror and legacy non-ownership paths with WebGL on/off; include context loss and fallback without changing session identity. Check native PTY dimensions in an isolated fixture, since an RPC echo is not a native-size acknowledgement. Add a failing regression for the confirmed trigger before selecting the final fix. No tests were executed for this issue. +- (inferred; agent's call) Next occurrence: the reporter can provide a cropped/redacted screenshot or short recording before and after the manual resize, noting whether only visual content or also wrapping/cursor position changed. Implement the R4 bounded in-memory diagnostic capture in a disposable development session using synthetic output; record scheduling reasons and non-content geometry rather than copying production buffers, logs, databases, or profiler files. A screenshot is supplementary evidence, not the sole acceptance gate. + +## Scope, constraints, and risks + +- (inferred) Likely change reach: shared viewport lifecycle/sizing and its helpers; center and right-panel caller integration; interaction/geometry/font/WebGL tests; a synthetic browser/desktop fixture; terminal lifecycle documentation if its guarantees change. No new exports, settings, dependencies, schema, persistence migration, or server change is justified yet. The client-runtime scheduler, RPC, manager, and PTY worker are traced boundaries to preserve, not assumed edit targets. +- (inferred) Main regression risks are stealing another window's size, replay/input reordering, dropping the last resize, repaint loops, and disturbing scrollback/selection. Preserve existing ownership, generation guards, batching and replay behavior; test those invariants with the rendering fix. + +## Not in scope + +- (inferred) Replacing xterm, disabling WebGL globally, adding a manual “repair” action, or unconditional terminal/process restart: none establishes or fixes the reported common trigger. +- (inferred) Changing PTY ownership rules, history retention, transcript protocol, or live terminal modes: existing semantics are independent invariants and no defect there is confirmed. +- (inferred) Production instrumentation runs, data extraction, process operations, or unrelated layout/theme redesign: this issue can be investigated and tested using synthetic sessions. + +## Related and duplicate check + +- (measured) Searched all open/closed issues with `gh issue list --repo mubeda/BibCode --state all --search "" --limit 100` for `terminal render`, `garbled`, `terminal resize`, `webgl`, `xterm`, and `terminal blank`. Result counts were 19, 0, 4, 0, 4, and 2 respectively; none hit the limit. (inferred) No same-symptom duplicate was identified in those results. +- (measured) [#284](https://github.com/mubeda/BibCode/issues/284) is closed and concerns center-split divider lag/chat artifacts; reachable history includes `c14f8536c7a3d6204bae713b28a65237f8fdb990` (“smooth center split resizing and contain composer paint”). (inferred) Related resize machinery, not evidence this intermittent AI-terminal report is the same bug. +- (measured) [#150](https://github.com/mubeda/BibCode/issues/150) is closed and concerns terminal modes after exited-shell replay; reachable history includes `ea58c1e200ff1ce6001fd70d800f767464242ff9` (“reset input modes after replaying an exited terminal”). (inferred) Preserve that fix; a live rendering fault is not automatically an exited-mode replay defect. +- (measured) Other relevant reachable history: `870dc6fb4a28bd7b39623b70bbc17adeacb10d78` (one window owns PTY size), `b48b73728c1bcf9c29b3ea27841d4b3194afa25f` (harden terminal rendering), and `2c257f29e696ce29ece5582597e22f15558cd04b` (WebKit canvas device-pixel correction). (inferred) These prior fixes narrow the investigation but do not prove current behavior or the reporter's version. + +## Open questions + +- (measured: missing reporter evidence) What exactly is displayed incorrectly, and is it corrected by any repaint or only a resize that changes terminal rows/columns? Owner: reporter/triage. A screenshot/recording can guide the reproduction without blocking filing; root-cause selection remains provisional. +- (measured: missing reporter evidence) Which build, AI command, and immediately preceding action were involved; does it occur on first attach, after returning to a tab, or during output? Owner: reporter/triage. These details prioritize fixture scenarios; the inspected SHA is not assumed to be the affected build. +- (measured: missing runtime evidence) Does the failing occurrence have different fitted, xterm, server-applied, and actual PTY dimensions, or correct dimensions with stale pixels? Owner: implementing engineer. Resolve with synthetic reproduction/diagnostics before calling any hypothesis the root cause. +- (measured: missing reporter evidence) Was another window attached as size owner, and did a font or display-scale change precede the failure? Owner: reporter/triage. This distinguishes intended mirror clipping from a fault; it does not change the requirement to preserve ownership. + +## Evidence and decision record + +- (measured) The owner explicitly requested filing; the task requires a static investigation and no interview. Those instructions supersede the skill's interview/approval workflow. This issue is not an approved implementation design. +- (inferred; agent's call) Use ISSUE form with ranked hypotheses, EARS requirements, Given/When/Then scenarios, and bounded non-goals; retain uncertainties rather than inventing the visual symptom or a deterministic reproduction. +- (measured) Source was read from the fetched `origin/main` only. CodeGraph was not used because database access was prohibited. Production data and processes were not accessed. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-350.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-350.md new file mode 100644 index 000000000..85df913c2 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-350.md @@ -0,0 +1,114 @@ +# #350 Terminal re-attach: keep a server-side screen state instead of replaying a raw byte tail + +## Problem + +A window that re-attaches to a terminal is rebuilt from the server's raw byte history (1 MiB or 5,000 lines, `apps/server/src/terminal/history.rs`). Once a program's last full paint has left that history, the rebuilt screen lacks cells, the buffer type (the alternate screen) and the input modes (bracketed paste, application cursor keys, mouse tracking). A program that repaints by cell diff then shows missing characters. Reproduced on a real pair, 5 of 5, and present in v0.15.3. + +#349 adds a best-effort recovery for Claude terminals that BiBCode starts on Unix servers (a `SIGCONT` after a lossy attach, so the CLI re-enters its screen and repaints). Everything else is still affected: Codex and other programs, plain shells running a full-screen program, Windows servers, and the modes of any program that does not re-assert them on resume. + +## Proposed direction + +The server keeps an emulated terminal screen per session and a snapshot becomes a checkpoint: bytes that rebuild the screen, both buffers, the modes and a bounded scrollback, followed by live output from an exact sequence. + +## Results of a crate spike (standalone project, compared cell by cell against xterm.js 6.0.0) + +Verdict +- No crate produces the checkpoint as is. +- avt 0.20.0 (Apache-2.0, 1 dependency, 0 unsafe) is the only workable base: with a wrapper of about 260 lines it passed every continuation case, every split-sequence position and streams 1, 2, 3, 5, 6 with 0 differing cells. Stream 4 (feature mix) differs in 36 cells: underline styles, `58;5;n`, OSC 8. +- vt100 0.18.1: not usable (no reflow, no DEC line drawing, no custom tab stops, no insert mode, no ?7, saved cursor not exposed). +- alacritty_terminal 0.26.0: not built; keeps the inactive grid, tab stops, scroll region and charset private; 38 dependencies. +- Main blocker: widths. Both crates use unicode-width 0.2.3; the installed xterm.js 6.0.0 uses Unicode 6 widths (emoji such as U+1F600 take 1 column in the client, 2 on the server). avt also gives zero-width characters a column. Options: load xterm's unicode11 or graphemes addon in the client (programs like the Claude CLI assume modern widths anyway), or fork the width function. + +Wrapper parts (about 260 lines): side tracker for input modes, title, scroll region mirror (~100); hold-back feed of the unfinished escape tail, capped 64 KiB (~20); dump rewrite (DECRSPS to CUP, CTC to HTS/TBC, colon truecolour) (~55); SGR intensity fix (~40); scrollback serializer and pen SGR (~50). + +Restoration order that passed: title; scrollback; primary paint; tab stops; primary saved cursor (with margins and origin if saved in origin mode, pending wrap by reprint); if alt active: CUP to primary cursor, SGR 0, `?1047h` (not `?1049h`), alt paint, alt saved cursor; DECSTBM; `?6h` only right before the final CUP; pen SGR, `?25l`, charsets, IRM, `?7l`, LNM, DECCKM; side-tracked modes (`?2004h`, mouse, `?1006h`, `?1004h`, keypad, cursor style); the raw held-back bytes. Note: re-enabling `?1004h` makes xterm.js send `CSI O` at once; the client's replay guard must drop it. + +Numbers (release, one thread): avt alone 199 MiB/s (TUI) and 173 MiB/s (shell); with wrapper and hold-back 71 and 67 MiB/s (upper bound of the hold-back cost). Checkpoint time 0.14 ms for a TUI, 1.5 ms at 1,000 scrollback lines, 8.8 ms at 5,000. Memory at 120x40 with 5,000 scrollback lines: 13.3 MiB; empty scrollback 0.2 to 0.3 MiB. Checkpoint size: TUI about 4 KB; 1,000 lines about 161 KB (naive serializer). + +Accepted losses unless more work: OSC 8 links, underline style and colour, `?2026` state, DCS/OSC prefixes over 64 KiB. Not tested: the real Claude CLI stream, ConPTY output, images, kitty keyboard flags, width growth, resize on the alt screen, invalid UTF-8. + + +## Requirements from the design review + +The first design was reviewed and deferred. Its findings are the requirements for this work: + +1. **P1 — A screen image is insufficient to resume the stream.** `apps/server/src/terminal/manager.rs:1881`. The spike must prove continuation equivalence, including both buffers, their saved cursors/attributes, scroll regions, origin and wrap modes, pending wrap, tab stops, character-set designation/selection, SGR, cursor style/visibility, title, input modes, and synchronized-output state. Otherwise a later restore-cursor, tab, scroll, or alternate-screen exit corrupts an apparently correct checkpoint. + + `vt100`’s formatter emits the active grid and selected modes; it is not a complete serialization of its parser and hidden state. Alacritty also keeps relevant state private, including tab stops, scroll region and inactive grid. **Smallest change:** make complete state access and continuation tests the crate-selection gate; withdraw the “small wrapper” and “300–500 lines” assumptions. [vt100 source](https://docs.rs/vt100/latest/src/vt100/screen.rs.html), [Alacritty source](https://docs.rs/alacritty_terminal/latest/src/alacritty_terminal/term/mod.rs.html). + +2. **P1 — Sequence alignment does not preserve an unfinished escape sequence.** `apps/server/src/terminal/manager.rs:3380`. An output event can end with `ESC[5;`; the next event can contain `3HX`. A checkpoint containing only the screen loses that parser prefix. I reproduced this with installed xterm: uninterrupted output placed `X` at row 5, column 3; checkpoint-style reset followed by the continuation printed `3HX` on row 1. + + **Smallest change:** include pending parser input/state in the checkpoint contract, or checkpoint at a complete parser boundary and retain the exact intervening bytes and sequence coverage. Cover fragmented CSI, OSC, DCS, ST, character-set escapes, and synchronized-output transactions. Waiting indefinitely for a complete control string needs a bound and explicit failure policy. + +3. **P1 — The proposed restoration order moves the restored cursor.** `apps/web/src/components/terminalReplyGuard.ts:51`. “Scroll region → cursor and SGR → modes” fails when modes include origin mode: xterm’s `DECSET ?6` homes the cursor. I reproduced the proposed ordering placing the next character at the region’s top-left instead of the saved position. + + Likewise, entering `?1049h` saves the normal cursor, so constructing the alternate buffer can overwrite state needed when it exits. **Smallest change:** specify an executable restoration algorithm: neutral painting state, buffer construction, saved-state construction, positioning-affecting modes, then final cursor/attributes/pending-wrap restoration. Test subsequent output and buffer exit, not just checkpoint appearance. + +4. **P1 — Query-free checkpoints remove the existing startup-query handoff.** `apps/server/src/terminal/manager.rs:3571`; `apps/web/src/components/terminalReplyGuard.ts:100`. A program can emit DA/DSR before its first viewer attaches and wait for the answer. Today `firstAttachmentGrant` deliberately permits those historical queries once. An emulator that discards queries followed by a query-free checkpoint loses that request. + + The guard also does **not** prevent an owning xterm from generating replies during ordinary replay: it blocks their forwarding through `acceptInput()`. My probe confirmed DA/DSR were emitted but dropped during ordinary replay, and forwarded with the startup grant. + + **Smallest change:** define a generation-scoped, once-only startup-query handoff separately from checkpoint restoration. Checkpoints must never receive that grant. Keep the server emulator disconnected from PTY writes and discard emulator reply events—including Alacritty’s `PtyWrite` and query callbacks. Test DA, DSR, DECRQM, DECRQSS, colour queries, and focus reports triggered by restoring `?1004h`. [Alacritty events](https://docs.rs/alacritty_terminal/latest/alacritty_terminal/event/enum.Event.html). + +5. **P1 — Checkpoint plus capped deltas still lacks a valid replay protocol.** `packages/client-runtime/src/state/terminalTranscriptRuntime.ts:292`, `:324`, `:381`. There is no client-requested resync API today; the existing resync is server-initiated after stream lag. More fundamentally, the runtime stores output bytes but only the **latest** size. A second renderer joining after checkpoint → output → resize → output receives all bytes at the final dimensions, even without eviction. + + **Smallest change:** retain checkpoint dimensions and an ordered journal of output and size changes, or invalidate cached replay after a geometry change and obtain a fresh checkpoint before hydrating another renderer. Define the actual refresh mechanism, single-flight behavior, generation fencing, cancellation, and event buffering during refresh. Never replay a checkpoint followed by deltas with an evicted middle. + +6. **P1 — The old-client compatibility claim is false.** `packages/client-runtime/src/state/terminalTranscript.ts:43`; `apps/server/src/terminal/manager.rs:4966`. The cap cuts at a UTF-8 boundary, not a checkpoint or escape boundary. The indispensable state alone can exceed 512 KiB: the server accepts 1,000×500 terminals and two screens. + + I tested a 1,007,792-byte two-screen checkpoint. After the legacy cut, the alternate screen appeared intact, but exiting it restored a damaged normal screen: its first row contained 228 characters instead of 1,000. No scrollback was involved. + + **Smallest change:** negotiate checkpoint support in the attach request and keep an explicitly limited legacy path, or prove the entire required checkpoint fits every legacy constraint. An optional response field cannot make old clients retain it. Add the field to Rust’s `apps/server/src/terminal/model.rs:228` as well as the TypeScript schema. Mixed windows need per-attachment format selection over the same canonical state. + +7. **P1 — The resize hook list misses the PTY’s eventual retained size.** `apps/server/src/terminal/manager.rs:3964`. `publish_kept_size()` changes dimensions after resize retries are exhausted. Updating the emulator only at `:3923` leaves it at the refused dimensions while clients apply the retained dimensions; later output and checkpoints diverge. + + **Smallest change:** update emulator geometry at every authoritative dimension transition, including `publish_kept_size()`, under the existing ordering locks. Ownership-only and equal-size acknowledgement events must not resize the emulator. Test requested size → output → retained-size correction → output → attach. + +8. **P1 — The emulator’s input already has a lossy boundary.** `apps/server/src/terminal/manager.rs:2911`, `:3411`. The publisher can receive `Lagged` from the PTY output broadcast. Its gap handler repairs private-output redaction state; it cannot recover missing terminal commands. An omitted alternate-screen switch or scroll-region command leaves the emulator permanently wrong, while the design labels its checkpoint lossless. Extra parsing can increase this exposure. + + **Smallest change:** give authoritative emulation a lossless ordered input path, or explicitly invalidate checkpoint authority on upstream loss and define recovery. A downstream attach resync cannot repair an already-corrupted server emulator. Distinguish upstream loss from a viewer merely falling behind. + +9. **P1 — Unsupported state becomes permanently absent from new attaches.** `apps/server/src/terminal/manager.rs:1881`; `apps/web/src/components/ThreadTerminalPanel.tsx:1437`. Keeping raw history exclusively for `read_history` does not preserve unsupported data for viewers. OSC 8 is supported by this client; dropping link attributes makes reattached text lose its link. Similar losses apply to palette changes and other supported setters. Blindly appending unknown raw sequences afterward is unsafe because their original ordering and cursor context matter. + + The installed xterm uses Unicode provider **6**; my probe gives U+1FAE0 width 1. Server width and grapheme handling must match the actual client configuration, including combining marks, variation selectors, ZWJ sequences and right-margin wide characters. + + **Smallest change:** define a supported-state matrix and explicit unsupported-state policy before choosing the crate. Preserve OSC 8 and supported colour state. Images are currently outside this panel’s installed addons; do not claim image preservation. Require width/reflow differential tests against xterm. `vt100` explicitly truncates/extends rows on width changes rather than reflowing them. [vt100 resize contract](https://docs.rs/vt100/latest/vt100/struct.Parser.html). + +10. **P2 — The scrollback proposal silently reduces retention and destroys soft-wrap information.** `apps/web/src/components/ThreadTerminalPanel.tsx:1429`; `apps/server/src/terminal/history.rs:290`. Today a small-line shell transcript can retain roughly 5,000 lines within both byte caps. A 1,000–2,000-line emulator limit loses previously available output. Serializing every physical row with CRLF turns soft wraps into hard line breaks, changing later reflow, selection and text-search semantics. + + **Smallest change:** retain the current intended history depth subject to an explicit memory budget, and preserve logical-line/soft-wrap boundaries and attributes. Checkpoint scrollback can improve colour consistency and remove obsolete repaint bytes, but cannot preserve text that was overwritten on-screen as an operational transcript. There is no terminal SearchAddon in this panel today; do not describe search parity as already implemented. + +11. **P2 — The cost estimate is not a bound, and snapshot work needs its own budget.** `apps/server/src/terminal/manager.rs:3370`, `:1873`; `apps/server/src/production/server_terminal.rs:964`. Synchronous parsing extends the publication/session critical section, delaying typing, resize, attach and close. Serialization scans retained cells under that lock too. The xterm throughput measurement establishes no Rust upper bound. + + At permitted maximum dimensions, two grids plus 2,000 scrollback rows represent up to three million cells—about 36–72 MB using the design’s own cell estimate, before auxiliary allocations, raw history and snapshot copies. Exited sessions retain history, so this is not only a running-terminal cost. + + **Smallest change:** gate the spike on measured lock hold time, input latency, burst throughput, upstream lag, attach latency and retained RSS across many terminals. Bound checkpoint bytes and auxiliary state; account for initial/restart snapshots as well as resyncs. Profiler coverage should include parse/serialize duration, queue lag and checkpoint size without recording terminal content. + +12. **P2 — A smaller retention heuristic cannot establish a valid checkpoint.** `apps/server/src/terminal/history.rs:258`. `ED 2` clears cells without resetting cursor, modes, saved state or the hidden screen. Alternate-screen entry also depends on earlier normal-screen state. Retaining from either marker therefore remains insufficient, and a hard cap eventually recreates the reported long-running failure. + + **Smallest acceptable alternative:** a rolling emulator state plus a bounded ordered journal, advanced on attach and before journal eviction. This can batch parsing and move work out of the session critical section, provided snapshots fence an exact generation/sequence and trimming never outruns committed parser progress. It still requires the same fidelity; sustained output eventually requires parsing every byte. Retain the parser itself rather than reconstructing it repeatedly from an incomplete display serialization. + +13. **P2 — The spike tests can pass while the implementation remains broken.** `packages/client-runtime/src/state/terminalTranscriptRuntime.test.ts:68`; `apps/web/src/components/terminalReplyGuard.test.ts:35`. A hand-authored checkpoint fixture and 40 ordinary diff frames do not exercise the Rust serializer, pending parser state, hidden buffers or cached resize ordering. + + **Smallest change:** generate fixtures with the actual pinned Rust implementation and compare uninterrupted xterm against checkpoint-plus-continuation at varied chunk boundaries. Include the failures above, saved-cursor restoration, tabs/charsets, normal-screen return, OSC 8, wrap/reflow, synchronized-output completion, startup/live reply counts, clear/restart/exit, stale generations, upstream/downstream lag, refresh during output and legacy truncation. Exercise the actual attach/runtime/panel path. The licences match the proposal—MIT and Apache-2.0—but maintenance and serializer effort remain unproved; a one-day deadline is not an acceptance criterion. [vt100 manifest](https://docs.rs/crate/vt100/latest/source/Cargo.toml), [Alacritty manifest](https://docs.rs/crate/alacritty_terminal/latest/source/Cargo.toml). + +14. **P2 — The resize investigation must distinguish notifications from PTY changes.** `apps/server/src/terminal/manager.rs:3586`, `:3911`; `apps/web/src/components/ThreadTerminalPanel.tsx:1910`. Same-size `resized` events are intentional ownership changes or acknowledgements. Existing-session attach dimensions do not themselves resize the PTY. + + The actual 85×33 request can arise afterward: applying the snapshot triggers a layout/attach claim, which measures the mount and sends a resize at `:1847`. Automatic settling considers mount pixels, with a 100 ms quiet period and 500 ms maximum wait; font metrics and delayed Activity-dock layout can change fitted rows afterward. The dock explicitly consumes terminal rows at `:2934`. + + **Smallest change:** instrument trigger, mount dimensions, fitted dimensions, font metrics, claim and actual PTY resize calls. These are source-backed candidates, not a reproduced attribution of the three rows. Keep the fix separate, but prove checkpoint replay causes no unintended PTY dimension changes. + +15. **Sound — The synchronous checkpoint’s sequence boundary and basic lifecycle placement.** `apps/server/src/terminal/manager.rs:1958`, `:3380`, `:3636`, `:4891`. Feeding exactly the redacted published bytes before advancing sequence, then reading emulator state and sequence under the same session lock, preserves the existing snapshot/live handoff. Subsequent **events** advance the sequence; the next output need not be exactly `sequence+1` because other event types consume numbers. + + Fresh state on generation creation, reset alongside the existing clear/RIS semantics, and retaining final state after exit fit current behavior. Preserve final output draining and redaction-tail flushing. The exited suffix remains usable only when serialized buffer transitions accurately describe the checkpoint. + +16. **Sound — Keeping operational history separate, and absent-format fallback.** `apps/server/src/terminal/manager.rs:4434`; `apps/server/src/mcp/tools/terminals.rs:522`; `packages/contracts/src/terminal.ts:270`. Keeping raw history preserves MCP raw/stripped reads, byte cursors, epochs and dropped-window reporting. An absent `historyFormat` can preserve this client’s current behavior with older servers, including its known replay limitation. + +Read-only verification used source inspection and two in-memory Node probes (`node -e …` and `node --experimental-strip-types --input-type=module -e …`). No files changed; the pre-existing untracked file remains. No Rust spike, full suites, native E2E or real-machine reproduction ran. CodeGraph sync was skipped because this review prohibited writes. + + + + +## Comment 2026-11-13T04:00:51Z + +Another case this issue covers, found while validating mubeda/BibCode#349 with the macOS app as client: a re-attach replays the raw byte history into a fresh terminal at the PTY's current size. When the PTY was resized while that window was away and the history is complete (so no repaint is requested), bytes written for the old size land differently at the new size. With a stand-in program that repaints on resize without erasing the whole screen, the returning window showed 4 stale rows that an attached window did not (5 of 5 runs). A headless xterm.js probe reproduces the class without a browser (all 39 rows differ for a program using relative cursor moves; 0 rows when the program erases the screen on resize or uses only absolute moves). Whether the real Claude CLI is affected is not known. A narrower stopgap would be to also request the repaint for a Claude provider terminal when the PTY size changed since the history began; its cost is a signal on most attaches in two-window use. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-351.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-351.md new file mode 100644 index 000000000..2a6bd5fba --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-351.md @@ -0,0 +1,9 @@ +# #351 Windows: expanding a reasoning row scrolls the chat list to its end + +On Windows (WebView2), expanding a reasoning ("thinking") row in the chat scrolls the message list to its end. The packaged spec `chat-reasoning-rows` fails on it: the expanded header moved 1014 px against a 2 px limit. + +Measured in a Windows 11 arm64 VM: before the toggle `scrollTop` was 0; between 16 and 50 ms after expanding, the list scrolled itself to the end. The scroller has `overflow-anchor: none`, so this is the timeline revealing the end, not browser scroll anchoring or a measuring error. The probe ran at a 1000x720 CSS viewport at device pixel ratio 2 against a 1280x760 request; a run at the requested viewport would tell WebView2 apart from the narrower viewport. + +Fails the same way on `main` (found while validating #349). Not traced: which timeline path reveals the end. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-352.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-352.md new file mode 100644 index 000000000..8d0a34d92 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-352.md @@ -0,0 +1,7 @@ +# #352 Flaky under load: removal_refuses_when_an_admission_does_not_drain_before_the_deadline + +`production::worktree_catalog_rpc::...removal_refuses_when_an_admission_does_not_drain_before_the_deadline` times out (`bounded admission drain: Elapsed(())`) in about one of three whole-suite runs of `cargo test -p bibcode-server` in a Windows 11 arm64 VM, on `main` and on #349 alike. Alone it passes 10 of 10 (2.8 s against a 2 s bound in one loaded run). + +It is a timing bound under load, not a behaviour change. Make the test wait on the condition with a generous deadline, or control time. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-353.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-353.md new file mode 100644 index 000000000..2c6e8cf39 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-353.md @@ -0,0 +1,14 @@ +# #353 Follow-ups from the #349 validation rounds + +Small follow-ups found while validating #349, none blocking: + +- Host dashboard on Linux: a second BiBCode AppImage mount (another running instance, or a stale FUSE mount left by a killed one) is shown as a full read-only volume. The app hides only its own image. +- Packaged e2e harness: after a web process crash the dead-webview probe runs once per worker, so the remaining shared specs still fail; one relaunch then hit a script timeout. +- Terminal re-attach: a transient size change of about three rows was seen right after a re-attach (85x30 to 85x33 and back). Candidates: the attach claim measuring the mount before fonts or the Activity dock settle. Instrument the trigger, mount size, fitted size and the PTY resize. +- "Fit to this window" gives no feedback when no size request can be sent (a request in flight, declined or awaiting its acknowledgement). +- The Host dashboard sheet re-renders once a second while open; memoize the values view if it shows in a profile. +- Mixed versions: terminal resize, attach and keybindings were not run against a real older server build (covered by contract tests and test doubles only). +- Linux packaged run: main-thread stalls up to 2.7 s on the settings to thread route change and WebKitWebProcess memory growing to about 1.0 to 1.26 GiB over a full run, both present before #349 (seen with the Profiler on under software rendering). +- xterm.js 6.0.0 counts emoji as one column (Unicode 6 widths) while programs such as the Claude CLI assume modern widths; loading the unicode11 or graphemes addon is also a prerequisite for a server-side screen. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-354.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-354.md new file mode 100644 index 000000000..6b8bf4a82 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-354.md @@ -0,0 +1,11 @@ +# #354 Git: lock handling for pull and hooks, sign-in detector, and two missing doc entries + +Small leftovers from the #341 and #343 work in #349. Not verified by a run: found by reading the code. + +- A forced stop while `git pull` is merging leaves its `index.lock` behind. `takes_index_lock` in `apps/server/src/git/process.rs` does not list `pull`, because it holds no lock while it fetches. Either list it with a watcher test, or record the limit in the code. +- A forced stop while a hook runs (pre-commit, post-checkout) relates to the lock ownership question raised in the review of #349: the hook runs without BiBCode holding the lock. Check this case against the ownership rule that #349 ends up with. +- `has_missing_username_refusal` in `apps/server/src/git/sign_in.rs` accepts any text that contains both "could not read username" and "terminal prompts disabled". A commit message or a hook's output that contains both phrases triggers the sign-in guidance. Anchor it to Git's own `fatal:` line. +- `docs/skills/bibcode-profiler/references/events.md` does not list the `git-writes` step among the `server.stop` quiesce steps. +- `docs/testing/cross-platform-validation.md` does not list the `background_status_`, `git_stop_` and `index_lock_cleanup_` test filters. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-355.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-355.md new file mode 100644 index 000000000..6823620b8 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-355.md @@ -0,0 +1,11 @@ +# #355 Pull-request lookups: teardown, failures and stale answers + +Follow-ups to the status-stream pull-request lookups in #349. Found by reading the code; none was reproduced by a run. + +- The shared lookup runs in a bare `tokio::spawn` in `OpenPullRequestLookups::admit` (`apps/server/src/source_control/open_pull_request_lookups.rs`). Quiesce cancels it but does not wait for it, so a `gh` child may be reaped after the profiler teardown. Track the task or join it at quiesce, and prove it with an injected fault. +- Failed lookups are never stored or backed off (`settle` removes the entry on error). With `gh` failing or slow and local events arriving steadily, the status stream in `apps/server/src/production/git_vcs.rs` runs lookups back to back. Add a short failure backoff. +- The request remembered for a branch (`GitBroadcaster::record_pull_request` in `apps/server/src/git/broadcaster.rs`) never expires. If the forge changes it under the same branch name while lookups keep failing, it stays shown until a lookup succeeds. +- `spawn_for_subscribers` drops the late lookup when nobody is subscribed. A refresh with no status subscriber therefore never warms the remembered request. +- Not decided: whether a status-neutral local change leaves a lookup answer from before an invalidation in place. A stream test with an invalidation that changes no status would decide it. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-356.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-356.md new file mode 100644 index 000000000..02d1eb399 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-356.md @@ -0,0 +1,11 @@ +# #356 Worktree creation records: delete by id, audit rollbacks, and edge cases + +Follow-ups to the worktree creation records (#344) in #349. Found by reading the code. + +- The creation rollback (dispatch failure) and the removal path in `apps/server/src/production/worktree_catalog_rpc.rs` call `delete_worktree_creation_record(path)`. A worktree recreated at that path in the gap loses its record. Delete by the id read when the worktree was resolved, as `reap_worktree_creation_record` does for the audit. +- Neither creation-rollback arm (record write failed, dispatch failed) calls `worktree_audit::record_removal_outcome`. The removal still happens but the profiler audit never sees it. +- `reap_gone_records` in `apps/server/src/diagnostics/profiler/worktree_audit.rs` treats NotFound as gone. A record on an unmounted volume is deleted although the folder still exists. +- A record can outlive its worktree if an unrelated folder is created at the same path later, which warns for that folder. A crash between Git's success and the record write leaves a worktree with no record. +- The tests for the `.git` pointer read pin the no-follow and non-blocking flags, not the ordering between the check and the open. The read is also not contained to a directory handle. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-357.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-357.md new file mode 100644 index 000000000..2d2945336 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-357.md @@ -0,0 +1,11 @@ +# #357 Desktop update install: the leave guard after a reload or a lost event + +Follow-ups to the unsaved-edits guard around a remote update install in #349. Found by reading the code. + +- A renderer reload during an allowed install forgets the block. `updateInstall` is a module variable in `apps/web/src/components/files/unsavedFileEdits.ts`, so the Files block is dropped and saving runs again while the install may still restart the app. +- If the `desktop:update-install-ended` event is missed or the allow reply is rejected, the "Installing an update" dialog (`UnsavedChangesCloseDialog.tsx`) stays up with no way out. Add one, such as asking the desktop for the install state again. +- A Files source registered during the install is not held. `blockForUpdateInstall` holds a snapshot of the sources, and `registerUnsavedSource` adds a source without taking a hold. +- Nothing ties the event name in `apps/web/src/tauriDesktopBridge.ts` to the one in `apps/desktop/src-tauri/src/remote_update_delegate.rs`. Add a test or a shared constant. +- Not decided: whether the always-open installing dialog makes the Manage devices toast unreachable. + + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-358.md b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-358.md new file mode 100644 index 000000000..58804aa07 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/sources/issue-358.md @@ -0,0 +1,14 @@ +# #358 Web and terminal: native-preview cover for popups, a size-claim capability, and lab observations + +Small web and terminal follow-ups from #349. + +- On macOS desktop, Popover, Menu, Select and tooltips can draw under a native browser preview. Sheet, Dialog and AlertDialog take an occlusion lease, but `useNativePreviewOcclusion` hides the whole preview and has no rectangle. Add a lease that carries a rectangle so the preview hides only when the popup overlaps it (`browserSurfaceStore`, possibly the desktop bridge). +- The client treats the `terminalUserInputClaim` server capability as proof that the server has the typed-last size hand-over rule. A server that advertises one without the other would break this silently. Add a dedicated capability (`packages/contracts/src/environment.ts`, `apps/server/src/lifecycle.rs`). +- Observed in lab runs without a known cause, to investigate: the WebGL terminal renderer sometimes falls back to the DOM renderer within a run (probably after reloads); an empty over-tall grid shows between a terminal's mount and its first snapshot after a reload; `terminal_command_activity_hint_decodes_strictly_without_entering_launch_env` was flaky before its close path was fixed and needs repeated runs to confirm; LAN pairing in the dev build answered "Invalid pairing token" once. +- Not measured: whether the composer's model picker can show old values for one render on a rapid reopen (#346). + + +## Comment 2026-11-12T23:21:22Z + +One more item, found while fixing a review comment on #349: the read-only rendered Markdown view (`ChatMarkdown`, used in Files while the rich editor loads, for files over 128 KiB, and for files the editor cannot change safely) still requests a relative image against the app's own address, so the image does not load. The rich editor now loads such images from the workspace; the read-only view should use the same loader. Same behaviour on `main`. + diff --git a/docs/plans/2026-11-13-issue-batch-348-358/spec.md b/docs/plans/2026-11-13-issue-batch-348-358/spec.md new file mode 100644 index 000000000..5a31acd4c --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/spec.md @@ -0,0 +1,258 @@ +# Issue batch: terminal fidelity, Git and worktree follow-ups, update guard and web fixes + +Status: Draft +Size: full Mode: gated Type: bug class and chore (#348, #351 to #358) and feature (#350) +Source: GitHub issues #348 and #350 to #358 of this repository as they stood on 2026-11-13 (copies in `sources/`), the conversation of 2026-11-13 ("process all open issues, fix them all in this PR"), and a read of `main` at `a9938193b` by five read-only verifiers. Evidence record: [evidence.md](evidence.md). #350 has its own folder, [../2026-11-13-terminal-screen-350/](../2026-11-13-terminal-screen-350/terminal-screen-design.md), because its design did not pass its first review. + +The ten issues hold 45 separate items. Most were filed as "found by reading the code" or "not decided", so each item was checked against the code first. This spec gives every item one disposition: + +- **Fix**: a requirement with a test that fails first. +- **Decide by test**: the test or probe is the deliverable. Its result picks the fix or records that there is no defect. +- **Record the limit**: the item is real, a fix is not justified, and the code or the docs say so. +- **Covered by**: another requirement in this spec delivers it. + +An issue is closed by the pull request only when every one of its items has evidence under its disposition. + +Names used throughout: + +- **Raw history**: the byte history the server keeps today (1 MiB or 5,000 lines). +- **Owner** and **mirror**: the window that sizes a terminal, and any other window that shows it. +- **Creation record**: the row BiBCode stores when it creates a worktree (#344). +- **Lookup**: the shared open-pull-request lookup behind the status stream. +- **Installing dialog**: the "Installing an update" dialog shown while a remote update install runs. + +## Problem + +- **#348.** Terminal rendering sometimes stays wrong until a panel is resized. #349 fixed three causes. Two gaps remain in the code: a font that loads late is never re-measured, and a terminal created after its host configuration arrives is not repainted when the bundled font finishes loading. There is no content-free record to tell a geometry fault from a repaint fault. +- **#350.** A window that re-attaches to a terminal is rebuilt from raw history. Once a program's last full paint has left that history, cells, the alternate screen and input modes are lost. Bytes written for one size are also replayed at another. #349's `SIGCONT` recovery covers only Claude terminals on Unix servers. +- **#351.** On Windows, expanding a reasoning row scrolls the chat to its end (1014 px against a 2 px limit in the packaged spec). +- **#352.** One worktree removal test times out in about one of three whole-suite runs under load. +- **#353 to #358.** Thirty-five smaller items left over from #349: wrong or missing behavior at edges (locks, lookups, creation records, the update guard, popups under a native preview), four lab observations with no known cause, and missing documentation. + +## Goals + +1. A terminal shows its current output with the right glyph metrics after a late font load or a late creation, without a manual resize. +2. Terminals count character widths the way current programs do. (The larger goal of #350, a re-attached window that equals one that stayed attached, is in the #350 folder.) +3. Expanding a reasoning row that is not at the end of its turn leaves the chat where it was. +4. Every item of #352 to #358 is fixed, decided by a recorded test, or recorded as a limit in the code or docs. +5. No test in this batch depends on a wall-clock bound that load can exceed. + +## Non-goals + +- Replacing xterm.js or changing its version (6.0.0 stays): no evidence a newer line fixes these items. +- The server-side terminal screen of #350: planned in its own folder (D2). +- A new CI lane that runs a new client against an older server binary: R19 runs the check once with a script; a lane is a separate decision. +- Signing in to Git from the client, new update-install UI beyond the installing dialog's exit, and changes to worktree record schema beyond returning the row id. +- Speculative fixes for the four lab observations when the bounded probes of R20, R43, R44 and R45 produce no signal. + +## Decisions + +- D1. One pull request covers all ten issues. Decided by: user, 2026-11-13. +- D2. #350 is planned in its own folder on the same branch, and no #350 wire code is written before its design passes a second review. Its first design failed review on continuation fidelity and on the refresh protocol, so its serializer is to be settled by a differential test harness, not by prose. The pull request closes #350 only if that work lands with proof; otherwise it references it. Alternative: hold the nine other issues until #350 is done (one unproven design would block 44 checked items). Decided by: agent's call after the review, to confirm with the user before the push. +- D3. A design or plan counts as approved once Codex and the advisor have reviewed it and their findings are handled (standing pre-approval of 2026-10-30). Decided by: user's standing rule. +- D4. The client switches to Unicode 11 widths now, in every terminal, whatever the server (R5). It is the prerequisite #350 names and fixes #353 item 8 on its own. Decided by: agent's call. +- D5. `pull` stays out of `takes_index_lock`. Its merge child holds the lock, the ownership check sees only the root process, and listing it would arm the Windows 2-second rule during the fetch. Decided by: agent's call, from the code. +- D6. A failed lookup is remembered for 5 seconds. A cancelled lookup is never remembered. An invalidation clears it. Decided by: agent's call. +- D7. A remembered pull request stops being shown when a lookup fails and the last successful lookup is more than 5 minutes old. Time alone never hides it: nothing looks up an idle branch, so a pure age limit would hide a valid request. Alternative: record the limit only (keeps a wrong request on screen for as long as the forge is unreachable). Decided by: agent's call, to confirm with the user at approval. +- D8. `spawn_for_subscribers` keeps dropping a late lookup when nobody subscribes: nothing would show the answer and the next refresh asks again. Decided by: agent's call. +- D9. A creation record is reaped only when its own folder is missing and that folder's parent directory exists. A missing parent means the audit cannot tell a removed worktree from an unmounted volume, so the record is kept. `git worktree list` cannot be the gate: it succeeds from a readable repository and only marks the missing worktree prunable. Cost: records under a managed directory that was deleted as a whole are never reaped. Decided by: agent's call. +- D10. No folder identity is added to creation records, and the `.git` pointer read is not moved to a directory handle. Both would be schema or boundary changes for an advisory, warning-only read. Decided by: agent's call. +- D11. Tooltips take no occlusion lease: a lease on hover would flicker the native preview. Decided by: agent's call. +- D12. A dedicated `terminalSizeHandOver` capability is added although no released server advertises one flag without the other; it is cheap and removes a silent coupling. Decided by: agent's call. +- D13. The Fit button keeps sending nothing when the server already declined that size; it says so instead. Decided by: agent's call, from the existing test. +- D14. Test names, fixtures and docs use placeholders only (repository rule on sensitive data). + +## Requirements + +Priority is Must unless marked. "Test seam" names the existing test file that takes the failing-first test. + +**#348 Terminal rendering** + +### R1. Late font load + +WHEN any font finishes loading in the document while a terminal is rendered, the terminal SHALL re-measure and repaint at its current size. +- Given a rendered terminal with an unchanged mount size, When `document.fonts` dispatches `loadingdone`, Then the terminal is re-measured and `clearTextureAtlas` and `refresh` each run once. An owner refits. A mirror keeps the server's columns and rows and sends no size claim: the test uses a fit mock that really resizes the terminal, as the real fit addon does, and asserts the mirror's grid is unchanged. The assertions for owner, mirror and the legacy path equal those of the existing device-pixel-ratio test. +- Given the terminal is unmounted, When `loadingdone` fires, Then nothing runs. +- Test seam: `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`, next to "refits when the device pixel ratio changes under an unchanged mount". +### R2. Late creation + +WHEN a terminal's renderer is created after its host configuration arrives, the terminal SHALL repaint once the bundled font has loaded, and a font callback for a disposed renderer SHALL do nothing. +- Given an AI terminal waiting for host configuration, When configuration arrives and the pending font load then resolves, Then `clearTextureAtlas` and `refresh` run on the new renderer and exactly one resize is issued for the initial geometry. +- Test seam: same file, "waits for authoritative host config before attaching Codex". +### R3. Same-size return + +WHEN a mount returns from no area to the same positive size, the terminal SHALL repaint. Test only: add the `refresh` assertion to "never sizes the PTY from a mount with no area and refits when it returns". +### R4. Size diagnostics + +WHERE development diagnostics are enabled, the terminal SHALL keep a bounded in-memory record of sizing and repaint decisions that holds no terminal content. +- Each record holds: trigger, renderer generation, mount width and height, fitted and applied columns and rows, owner or mirror, device pixel ratio, `document.fonts.status`, renderer kind, the size request and its outcome, repaint scheduled and done. +- The ring holds the last 200 records. With diagnostics off, nothing is recorded. +- Given output is written to the terminal, Then no record contains any written byte. + +**#350 Server-side terminal screen (only the client's width mode is in this spec)** + +### R5. Modern widths in the client + +Every terminal in the client SHALL count columns by Unicode 11 (the `@xterm/addon-unicode11` provider), so an emoji takes two columns as programs such as the Claude CLI assume. Given a real xterm built with the panel's options, When U+1F600 is written, Then the cursor advances 2 columns; before the change it advances 1. Covers #353 item 8. Server-side width parity belongs to #350. +**R6 to R11: withdrawn from this spec.** The server screen, checkpoints, negotiated attach, client replay, the recovery path and the budgets are specified and planned in the #350 folder. Its first design review (Codex, 2026-11-13) returned "needs rework" with 13 findings, so no #350 wire code is planned here. The numbers R6 to R11 stay reserved so that later numbers do not move. + +**#351 Reasoning rows** + +### R12. Reasoning row keeps its place + +WHEN the reader has brought a reasoning row into view away from the turn's end and expands or collapses it, the row's header SHALL stay within 2 px of where it was. This is the invariant the packaged spec `chat-reasoning-rows` asserts in `expandThoughtAwayFromEnd` (scroll the row to the center, toggle, compare the header's position). +- Diagnose first, before any fix. Step 1: run that spec in the Linux lab at a 1000x720 viewport. A failure there is a platform-independent reproduction (WebView2 is not needed); it does not by itself make the viewport the cause, so the viewport is then varied on its own. Step 2: in a unit test with the spec's metrics, record which code path scrolls (the toggle's end reveal, the grown-turn follow, or the list's end maintenance). The fix goes at the first wrong decision that step 2 names; no fix is chosen before it. +- Given the reader scrolled a reasoning row to the center without a wheel or touch gesture (a programmatic scroll), When the row is toggled, Then no scroll to the end is made within three animation frames. +- Given the newest reasoning row at the end of an anchored turn, Then the existing reveal still happens (the #173 behavior, asserted by `expandStreamingThoughtAtEnd`). +- Proof on Windows: the packaged `chat-reasoning-rows` spec passes in the Windows VM at the viewport where it failed. +- Test seam: `apps/web/src/components/ChatView.test.tsx`, "a pointer press on a Thinking row's toggle before a toggle at an anchored turn's end". + +**#352 Flaky removal test** + +### R13. Deterministic removal test + +The test `removal_refuses_when_an_admission_does_not_drain_before_the_deadline` SHALL wait on the drain having been entered and on the removal's result, with a 30-second guard that only a real hang reaches. The injected 20 ms drain timeout stays. Proof, chosen in advance: (1) red first: with the removal delayed by 2.5 seconds before its drain (a test-only delay), the old test fails with `bounded admission drain: Elapsed` and the new test passes; (2) 50 of 50 runs of the test alone under CPU load on the Mac; (3) 12 of 12 whole-suite runs in the Windows VM (a 1-in-3 flake passes 12 runs less than 1% of the time). + +**#353 Follow-ups from #349** + +### R14. Other AppImage mounts + +A read-only FUSE mount whose mount point's last component starts with `.mount_` SHALL be hidden from Host dashboard volumes as a system mount, whether or not this process runs from it, and SHALL never be read. +- Given `/tmp/.mount_OtherY` with file system `fuse.BiBCode-1.0.AppImage` and the executable elsewhere, Then it is hidden. Given `/mnt/tools` with `fuse.gocryptfs`, Then it is shown. +### R15. Dead webview probe + +The packaged e2e harness SHALL check for a dead webview before every test, not once per worker, and relaunch within the test's time allowance. The "per spec file" comment is corrected. +### R16. Re-attach size transient (decide by test) + +Using R4's record, a test replays an attach followed by a font-metrics change and an Activity dock change. IF a size request with different rows is sent and then reverted, the settle rule is fixed so that only the final size is requested; otherwise the result is recorded and no code changes. +### R17. Fit feedback + +WHEN "Fit to this window" is pressed and no size request can be sent, the size notice SHALL say that the server kept this size, in a status region. No request is sent and focus stays on the button. +### R18. Host dashboard re-render (Should) + +The values view SHALL not re-render when only the clock tick changed. +### R19. Mixed versions (decide by probe) + +A script starts the previous release's server and drives terminal attach, resize and keybindings from the current client runtime. Pass: each call answers through its legacy path with no error. A failure becomes a fix in this batch. +### R20. Linux stalls and memory (decide by probe) + +One packaged run in the Linux lab with the Profiler on, covering the settings to thread route change ten times. A `client.stall` or `memory.leak-*` finding with a trace becomes a diagnosis in this batch; without one, the run and its numbers are recorded and nothing changes. +- Item 8 (emoji widths): covered by R5. + +**#354 Git leftovers** + +### R21. Pull (record the limit) + +The comment at `takes_index_lock` SHALL state D5. +### R22. Hooks (record the limit) + +The comment at the forced-stop cleanup SHALL state that a lock taken while a hook runs is kept on Linux and macOS (the killed root did not hold it open), and that Windows can still remove a foreign lock under the 2-second rule. The existing test `index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write` is the proof. +### R23. Sign-in detector + +The missing-username refusal SHALL be detected only on a line that starts with `fatal:` and contains both phrases. +- Given a commit message or hook output that contains both phrases on a line without `fatal:`, Then no sign-in guidance is shown. +- Given Git's line `fatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled`, with or without leading spaces and in any letter case, Then guidance is shown. +- Both callers' inputs are checked first: if either joins lines, the detector splits on that join as well. +### R24. Quiesce step documented + +`docs/skills/bibcode-profiler/references/events.md` SHALL list `git-writes` among the `server.stop` quiesce steps, in its real position. +### R25. Test filters documented + +`docs/testing/cross-platform-validation.md` SHALL list the `background_status_`, `git_stop_` and `index_lock_cleanup_` test filters. + +**#355 Pull-request lookups** + +### R26. Teardown + +Quiesce SHALL wait for every running lookup to end before the profiler's teardown, and a lookup admitted after shutdown SHALL end at once as cancelled. +- Given a lookup whose child exits 200 ms after cancellation (injected), When shutdown is called, Then it returns only after that lookup has ended. +- The new quiesce step is documented with R24. +### R27. Failure backoff + +WHEN a lookup fails, the same key SHALL answer with that failure for 5 seconds without starting a new lookup (D6), and the failure SHALL be logged once at `warn` without the remote's URL. +### R28. Expiry + +WHEN a lookup fails and the last successful lookup for that branch is more than 5 minutes old, the remembered pull request SHALL stop being shown (D7). Without a failed lookup it stays, however old. A ref change clears it as today. +- Given a request remembered 6 minutes ago and a lookup that fails now, Then the next status has no pull request. +- Given a request remembered 6 minutes ago and no lookup since, Then it is still shown. +### R29. Late lookup with no subscriber (record the limit) + +The doc comment at `spawn_for_subscribers` SHALL state D8. +### R30. Neutral change (decide by test) + +A stream test invalidates during a lookup with a local change that alters no status. IF the answer from before the invalidation is shown with no follow-up lookup, a follow-up lookup SHALL be started; otherwise the test stays as proof. + +**#356 Worktree creation records** + +### R31. Delete by id + +Creation rollback and removal SHALL delete the creation record by the id read when the worktree was created or resolved. Given a worktree recreated at the same path between resolve and delete, Then the new record survives. +### R32. Audit rollbacks + +Both creation-rollback arms SHALL record a removal outcome with mode `create-rollback` and a fixed reason token, never a path. The mode is documented in the profiler events reference. +### R33. Unmounted volume + +The audit SHALL reap a creation record only when its folder is missing and the folder's parent directory exists (D9). +- Given a record whose folder and parent directory are both missing (the volume is unmounted), Then the record is kept. +- Given a record whose folder is missing and whose parent directory exists, Then the record is reaped as today. +### R34. Creation record limits (record the limit) + +Comments SHALL state that a record can outlive its worktree when another folder takes its path, that a worktree with no record is still recognized by its `.git` pointer file, that the pointer read checks the opened file (no check-then-open gap), and that containment covers only the last path component (D10). + +**#357 Update install guard** + +### R35. Reload + +WHEN the window loads while a remote install it allowed is still protecting or installing, the unsaved-edits block SHALL be restored. A desktop without the state query counts as "not installing". A query that fails SHALL be retried and SHALL never release a block: only a confirmed state with no remote install in progress releases it. +- Given a reload during an allowed install and a first state query that rejects, Then saving stays held and the query is asked again; When it then answers that no remote install is in progress, Then the block ends. +### R36. Way out + +WHEN the desktop's update state shows no remote install in progress, the installing dialog SHALL close even if the ended event never arrived. The dialog SHALL also offer a button that asks the desktop for the state again. +### R37. Late sources + +A Files source registered during the install SHALL be held until the install ends. +### R38. Event names + +A test SHALL fail when a `desktop:` event name exists on only one side of the bridge. +### R39. Toast reachability (decide by test) + +A DOM test opens the installing dialog over the remote-update toast. IF the toast's action is inert, the action SHALL be reachable from the dialog; otherwise the test stays as proof. + +**#358 Web and terminal** + +### R40. Popup occlusion + +On macOS desktop, an open Popover, Menu, Select, Combobox or Autocomplete popup SHALL hide a native browser preview only while its rectangle overlaps the preview's, and release it when it closes. Dialogs keep hiding the whole preview. Tooltips take no lease (D11). +### R41. Hand-over capability + +The client SHALL apply the typed-last size hand-over rule only when the server advertises `terminalSizeHandOver`. A server without it gets the old behavior. +### R42. WebGL after a failure + +A new terminal in the same viewport SHALL try WebGL again after an earlier context loss, and the fallback warning SHALL include the cause. +### R43. Tall grid (decide by test) + +A test records rows and mount height between mount and first snapshot after a reload. IF rows exceed the fitted rows, the terminal SHALL be fitted before it is shown; otherwise the result is recorded. +### R44. Flaky hint test (decide by runs) + +`terminal_command_activity_hint_decodes_strictly_without_entering_launch_env` runs 50 times under CPU load. Any failure is diagnosed and fixed in this batch. +### R45. Pairing rejection + +A rejected pairing SHALL be logged at `warn` with its reason and without the token. +### R46. Model picker + +Reopening the composer's model picker SHALL show an empty search and the current values. +### R47. Read-only Markdown images + +The read-only rendered Markdown view in Files SHALL load a relative image from the workspace through the same loader as the rich editor, and refuse a path that leaves the workspace. Chat messages are unchanged. + +## Verification + +- `cargo fmt --all --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test -p bibcode-server`; `cargo test -p bibcode-desktop`. +- `vp check`; `vp run typecheck`; `vp run test`; `vp run check:contracts`; `vp run check:markdown-links`. +- Linux lab: the packaged default list with the Profiler on, plus the R20 run. +- Windows VM: `chat-reasoning-rows` (R12), the R13 runs, fmt, Clippy and `cargo test -p bibcode-server`. +- Review of changed React against `vercel-react-best-practices` and of changed UI against `UI.md`. + +## Open questions + +None that block planning. The agent's calls D4 to D13 are listed for the user with the plan's approval summary. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/terminal-screen-design.md b/docs/plans/2026-11-13-issue-batch-348-358/terminal-screen-design.md new file mode 100644 index 000000000..44fe75910 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/terminal-screen-design.md @@ -0,0 +1,453 @@ +# Design: server-side terminal screen state and attach checkpoints (#350) + +Status: **Draft for review** (Codex and the advisor). No code is written until both approve. +Source: GitHub issue #350 (crate spike and the 16 review findings, which are binding), this +batch's `spec.md` (R5 to R11, D2, D3), and the current code on `main`. Findings are cited as F1 to F16 in the issue's order. Every choice this +document makes that the issue left open is marked **(agent's call)** and listed in section 6. + +## 1. Problem, goal and scope + +When a window attaches again, the server sends its raw byte history (5,000 lines or 1 MiB, +`apps/server/src/terminal/history.rs`). Once the program's last full paint has left that +history, the rebuilt screen is missing cells, the alternate screen and the input modes. A +program that repaints by cell diff then shows missing characters. Bytes written at an older +size also land wrongly at the current size (the issue's comment: 4 stale rows). + +Goal: the server keeps an emulated screen per session. A client that asks for one gets an +attach **checkpoint**: bytes that rebuild both screens, the modes and a bounded scrollback, +followed by live output from an exact sequence. This works for every program and on Windows +servers. Old clients and old servers keep today's behaviour. + +Out of scope: images (no image addon is installed in the panel); kitty keyboard flags; +DCS/OSC strings longer than 64 KiB (handled by an explicit fallback, D5); search parity (there +is no SearchAddon). OSC 8 links and supported colour state are **not** dropped (F9): they are +kept in the checkpoint (D1, section 3). If a state the checkpoint cannot carry has been +emitted, that session falls back to today's raw history until the state is cleared (D3). + +## 2. Decisions + +### D1. Crate, vendoring and module layout (F1, F9, F13) + +Decision: use avt 0.20.0, vendored under `third_party/avt` and wired through the existing +`[patch.crates-io]` table in the root `Cargo.toml`, with an `UPSTREAM.md`. That is how +`third_party/portable-pty` is already vendored. The vendored copy carries three local changes, +each listed in `UPSTREAM.md`: + +1. its width function is replaced by a generated table (D2); +2. a zero-width character joins the previous cell instead of taking a column (F9); +3. the pen gains a hyperlink id (OSC 8), an underline style and an underline colour (`58;2`, + `58;5`). The spike found that these three were its only cell differences (stream 4). + +The wrapper lives in a new `apps/server/src/terminal/screen.rs`. The spike sized it at about +260 lines; with the pen and hyperlink serialization, expect about 400. It holds the parts +listed in the issue: side tracker, hold-back feed, dump rewrite, SGR intensity fix, scrollback +serializer. Its public surface: + +```rust +pub(crate) struct TerminalScreen { /* avt::Vt, side tracker, held-back tail, validity */ } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ScreenInvalid { UpstreamLag, UnsupportedState, TailOverflow, OverBudget } + +pub(crate) struct TerminalCheckpoint { pub data: String, pub scrollback_lines: usize } + +impl TerminalScreen { + pub(crate) fn new(cols: u16, rows: u16) -> Self; + /// Exactly the redacted bytes that were published, in publication order. + pub(crate) fn feed(&mut self, data: &str); + /// Every authoritative size transition, and nothing else (D4). + pub(crate) fn resize(&mut self, cols: u16, rows: u16); + /// `terminal.clear` (a full reset, like the RIS clients apply today). + pub(crate) fn reset(&mut self); + pub(crate) fn invalidate(&mut self, reason: ScreenInvalid); + pub(crate) fn validity(&self) -> Result<(), ScreenInvalid>; + /// Bytes that rebuild this state after a client's RIS, at most `max_bytes` (D6). + pub(crate) fn checkpoint(&self, max_bytes: usize) -> Result; +} +``` + +`TerminalScreen` has no handle to the PTY and no reply channel. The emulator cannot answer a +query (F4). + +Alternatives: +- vt100 0.18.1: no reflow, no DEC line drawing, no custom tab stops, no insert mode, no `?7`, + saved cursor not exposed (F1, F9). +- alacritty_terminal 0.26.0: keeps the inactive grid, tab stops, scroll region and charset + private; 38 dependencies (F1). +- `@xterm/headless` in the server: needs a Node runtime, which AGENTS.md forbids. +- Using avt from crates.io unpatched: its widths do not match the client (D2) and it would + lose OSC 8 (F9). + +### D2. Width parity (F9, F13) + +Decision **(agent's call)**: the client loads `@xterm/addon-unicode11` 0.9.0 and sets +`terminal.unicode.activeVersion = "11"`. The server's vendored avt uses a width table +generated from that addon's own tables by a script in `scripts/`. A test fails when the +checked-in table differs from a fresh generation. Zero-width and combining marks join the +previous cell. VS16 and ZWJ are handled as the addon handles them: each code point is measured +on its own, with no grapheme clustering. A wide character at the right margin wraps and leaves +the last cell blank, as xterm does. + +The `unicode` API is marked experimental in the 6.0.0 typings. Phase 2's harness settles +whether `allowProposedApi: true` is needed; Phase 5 sets it in the panel if so. Unicode 11 is +active in every terminal, whatever the server **(agent's call)**: one width mode per client. + +Gate: the differential harness (section 4) must show zero differing cells for a width corpus: +every code point the table lists, emoji with and without VS16, ZWJ sequences, combining +stacks, and wide characters at column `cols - 1`. + +An old client never gets a checkpoint, because it never asks for one (D6). A new client asks +only after the unicode11 addon is active. The request value names the width mode +(`"checkpoint-unicode11"`), so a future width change is a new value, not a silent mismatch. + +Alternatives: +- `@xterm/addon-unicode-graphemes` 0.4.0: lost because the server would also have to + reproduce its grapheme clustering, which avt does not do, and the addon is younger. +- Leaving the client on Unicode 6 and forking the server to Unicode 6: lost because programs + such as the Claude CLI assume modern widths, so Unicode 6 is already wrong for them on screen. + +### D3. Upstream loss and unsupported state (F8, F9) + +Decision: the emulator is fed after redaction, in the publisher, so it never sees private +values. The PTY output broadcast (capacity 1024, `pty.rs:598`) can still report `Lagged` +(`manager.rs:2930`, `:2956`, `:2994`). On every upstream `Lagged`, the publisher calls +`invalidate(UpstreamLag)` under the same locks as `publish_output_gap`. + +An invalid screen keeps being fed. It becomes valid again at `terminal.clear`, at a new +generation (restart), or when the stream itself carries a full reset (RIS), as `spec.md` R6 +requires. RIS counts only if the harness case `ris-revalidates` shows that RIS followed by the +rest of the stream equals a fresh terminal fed the same rest, side-tracked state included; +if it does not, RIS is dropped from the list and the spec's R6 is amended. + +`UnsupportedState` follows one rule: a setter that xterm 6.0.0 implements and the checkpoint +cannot carry invalidates the screen; a sequence xterm ignores does not. It clears at the same +points. + +While the screen is invalid, an attach that asked for a checkpoint gets today's raw history, +marked `historyFormat: "raw"`, plus the existing SIGCONT recovery where it applies (D11). + +A viewer that merely falls behind (the attachment's own `Lagged`, `manager.rs:4829`) is +downstream loss. It gets a fresh checkpoint through the existing resync and never invalidates +the screen. + +Alternative: feed the emulator in the PTY reader thread, before the broadcast, which would be +lossless. It lost because the reader runs before redaction, so the emulator and its +checkpoints would hold private values that clients must never receive. Moving redaction into +the reader is a separate, larger change. + +### D4. Where the screen is fed and resized; lock budget (F7, F11, F12, F14, F15) + +Decision **(agent's call)**: feed synchronously, under the publication lock and the session +lock, in the same block that already pushes history and advances the sequence. That block is +`publish_output_chunk` (`manager.rs:3383`) and `record_private_output` (`:1973`). Order: feed +the screen, push the history, then `advance()`. A checkpoint read under the same session lock +then matches `session.sequence` exactly (F15). + +Why not the batched alternative from F12? The spike measured 67–71 MiB/s with the wrapper. The +publisher already coalesces up to 64 KiB (`manager.rs:76`), so one feed takes at most about +1 ms, and a typical TUI frame of a few KB takes well under 0.1 ms. A batched design needs a +second journal, a parser-progress fence and trimming rules: more code and more failure modes +for no measured need. + +Budget, from `spec.md` R11, watched by the profiler (D12) and recorded by a release-mode +measurement in Phase 3 (a recorded number, not a pass or fail test) at 120 × 40 with 5,000 +scrollback lines and at 1000 × 500: checkpoint under 20 ms (spike: 8.8 ms), feed at least 50 MiB/s +(at most about 1.3 ms per 64 KiB chunk), retained memory under 16 MiB per session. If Phase 3 +measures worse, implementation stops and this decision reopens. + +Authoritative size transitions call `screen.resize` under the same locks, right where +`session.cols` and `session.rows` change: `resize_acknowledging` (`manager.rs:3952`) and +`publish_kept_size` (`:3993`, F7). A new session or restart creates a new screen at its open +size (`:2743`). + +Ownership-only changes (`:3605`, `:3625`, `:4051`, `:4084`), same-size acknowledgements +(`:3938`) and refused-size answers (`:3924`) do not touch the screen. The invariant is that +the screen's size always equals `session.cols × session.rows`. A debug assertion checks it at +every checkpoint. + +Lifecycle: `terminal.clear` (`:4087`) calls `reset()`, since clients already apply a RIS on +`cleared`. Exit keeps the screen; the final drain and the redaction tail flush still feed it. +Restart builds a fresh screen with the new generation (F15). + +### D5. Checkpoint contents and restoration order (F1, F2, F3) + +The checkpoint assumes the client has just written RIS (`writeTerminalBuffer` does, +`terminalReplyGuard.ts:51`) and has resized xterm to the snapshot's `size`. It writes, in this +order (the spike's order, which passed): + +1. Title. +2. Palette and dynamic colours (OSC 4, 10, 11, 12), from the side tracker; global, so order + does not matter. +3. Scrollback, oldest first, painted on the normal screen and scrolled off. +4. Normal screen paint. +5. Tab stops (TBC, then HTS). +6. Normal-screen saved cursor: with margins and origin mode if it was saved in origin mode; a + pending wrap is restored by reprinting the last cell. +7. If the alternate screen is active: CUP to the normal cursor, `SGR 0`, `?1047h` (not + `?1049h`, which would overwrite the saved cursor), the alternate paint, then the alternate + saved cursor. +8. DECSTBM. +9. `?6h`, only right before the final CUP, because `?6` homes the cursor (F3). +10. Final CUP. A pending wrap is restored by reprint before `?7l`. +11. Pen SGR, including underline style and colour, and the open OSC 8 link if any. +12. Then: `?25l` if hidden, charsets (G0–G3 and GL), IRM, `?7l`, LNM, DECCKM. +13. Side-tracked modes: `?2004h`, mouse modes, `?1006h`, `?1004h`, keypad, cursor style. +14. `?2026h` if a synchronized-output transaction is open (xterm 6.0.0 supports the mode). +15. The raw held-back tail. + +Paint (steps 3, 4 and 7): runs carry their SGR and OSC 8 open and close. A soft-wrapped row is +printed to the right margin and continues without CR LF, so xterm marks the next row as +wrapped (F10). Hard line ends use CR LF. + +Held-back tail (F2): the wrapper's scanner keeps the bytes of an escape or control string that +has not finished (CSI, OSC, DCS, ESC with intermediates, an open ST) as raw text. It is capped +at 64 KiB. Past the cap, the wrapper drops the held bytes and reports `TailOverflow` until the +parser is back in ground state; checkpoints in that window fall back to raw (D3). Nothing waits +forever. + +### D6. Wire contract and per-attachment format (F6, F14, F16) + +| Item | Contract | +|---|---| +| Capability | `terminalScreenCheckpoint: true` in `lifecycle.rs` capabilities; `Schema.Boolean` with default `false` in `packages/contracts/src/environment.ts`. | +| Request | `terminal.attach` payload gains optional `historyFormat: "raw" \| "checkpoint-unicode11"`. Rust: a `history_format` field on `TerminalAttachPayload` (`server_terminal.rs:1679`, no `deny_unknown_fields`, so an old server ignores it) and `TerminalAttachInput` (`model.rs:124`). TypeScript: `TerminalAttachInput` (`contracts/src/terminal.ts:117`). Absent means `"raw"`. | +| Response | `TerminalSessionSnapshot` (`model.rs:228`, `terminal.ts:270`) gains optional `historyFormat: "raw" \| "checkpoint"`. When it is `"checkpoint"`, `history` holds checkpoint bytes and `size` is the checkpoint's size (same lock). Absent means raw, so a new client works unchanged with an old server (F16). | +| Who asks | A client sends `"checkpoint-unicode11"` only when the capability is advertised, size ownership is on (xterm then takes the server's size, `ThreadTerminalPanel.tsx:1905`), and the unicode11 addon is active (**agent's call**). | +| Selection | `TerminalAttachment` stores its format. Attach (`manager.rs:3655`) and lag resync (`:4952`) build the history in that format under the publication and session locks. `Started` and `Restarted` are shared events (`:2860`). For a checkpoint attachment, `deliver` (`:4862`) re-reads them under the same locks, like a resync, and sets `next_sequence` to that read's sequence. `terminal.open` answers (`:2325`, `:2809`) stay raw. | +| Size bound | `CHECKPOINT_MAX_BYTES = 4 MiB` (**agent's call**). Over the bound, the oldest scrollback lines are dropped first. If the two screens alone exceed it, `checkpoint` returns `OverBudget` and the answer is raw. The existing resync capacity wait (`server_terminal.rs:966`) still applies. | +| Windows | `LossyReplay`, `has_discarded_prefix` and `lossy_replay` are `#[cfg(unix)]` today (`history.rs:13–27`, `:203–218`). Phase 4 removes that gate; only the SIGCONT call stays Unix-only. | +| New RPC | `terminal.readCheckpoint` `{threadId, terminalId, historyFormat}` returns a snapshot (checkpoint or raw) at an exact `sequence`. Registered through the `rpc/session.rs` unary wrapper; not long-running. Gated by the same capability. Used only by D8. | + +Mixed windows each get their own format over the same canonical state. A legacy client is +never sent checkpoint bytes, so the 512 KiB legacy cut (`terminalTranscript.ts:43`) can never +cut a checkpoint (F6). + +Checkpoint replay itself never sends a resize. The client's existing claim rules decide, and a +web test proves it (F14). + +### D7. Startup-query handoff (F4) + +Decision **(agent's call)**: a checkpoint snapshot always has `firstAttachmentGrant: false` +(`spec.md` R8). The handoff is separate, as F4 asks. Until the generation's grant is used, the +side tracker records each query the program emits (DA, DSR, DECRQM, DECRQSS, OSC colour +queries), up to 16 queries and 1 KiB, in order. The one attach that wins the grant +(`manager.rs:3590`) receives them in a new optional snapshot field `startupQueries`. The +client writes them after the checkpoint through the existing grant path +(`replay(bytes, true)`), so its owning xterm answers them once. Answers use the restored +state: a cursor report gives the current position, as a late answer would today. A raw-format +attach keeps today's grant behaviour unchanged. + +Emulator side: `TerminalScreen` has no PTY handle, and the vendored avt's query answers (if +any) are discarded. + +Client side: checkpoint replays use the ordinary replay path, so `acceptInput()` is false while +they parse (`terminalReplyGuard.ts:128`). A web test restores `?1004h` and asserts that the +`CSI I` or `CSI O` xterm emits is dropped. If xterm emits it after the parse callback, the +guard's window is extended by one frame for checkpoint replays (decided by that test). + +Tests cover DA, DSR, DECRQM, DECRQSS, OSC 10/11 colour queries and the focus report: the +startup counts match today and live counts are unchanged. + +### D8. Client storage, second renderer and refresh (F5, F13) + +Decision **(agent's call)**: an ordered journal, with a single-flight refetch when it overflows. +`terminalTranscriptRuntime.ts` keeps, per process, a `base` (the checkpoint string, its size +and its sequence) and a `journal` (every accepted `delta` and `resized` since the base, each +with its sequence). + +The journal is capped at 1 MiB. Hydrating a renderer (`attachRenderer`, `:375`) sends +`reset(base, baseSize)` and then the journal entries in order to that sink only. Output that +arrived before a resize is therefore laid out at its own size (F5). + +Journal `resized` entries are replayed geometry only. They resize xterm and update the mirror +scale, but they skip the ownership logic in `applySize` (`ThreadTerminalPanel.tsx:1910–1914`), +which could otherwise send a claim (F14). + +When the journal overflows, or when the base is a raw answer to a checkpoint request that has +since seen eviction or a resize: +1. The runtime drops the base. A checkpoint is never replayed with an evicted middle. +2. It calls `terminal.readCheckpoint` once (single flight) and keeps journaling events. +3. It applies the answer at sequence S only if the answer's generation matches the current + process. It never waits: sequences are spent on events this client never receives, such as + other windows' acknowledgements (`manager.rs:4912–4920`). +4. It keeps the journal entries after S, raises the applied floor to S and clears the sequence + source, so every stream's later events at or below S are dropped. + +If 2 MiB pile up while the request is in flight, it is cancelled and retried once; after that +the runtime falls back to today's raw tail. `cleared`, `restarted`, a close or the last +renderer detaching cancel it. Renderers that attach meanwhile are hydrated when it lands. A +raw snapshot to a raw request keeps today's path. The live renderer is never affected. + +Alternative: replace the base after every size change by refetching. It lost because each +resize would cost an RPC and a large checkpoint, and two windows resize often (#348). + +### D9. Scrollback depth, soft wraps and memory (F10, F11) + +Decision **(agent's call)**: the scrollback limit is +`min(5_000, (1_600_000 − 2 × cols × rows) / cols)` lines, never below 0, so at most about 1.6 +million cells per session. That is the full 5,000 lines at typical widths: at 120 × 40 it +allows more than 13,000 lines and is capped at 5,000. At the largest permitted size, +1000 × 500, it allows 600 lines. + +The spike measured 13.3 MiB at 120 × 40 with 5,000 lines (inferred: about 23 bytes per cell). +The bound is therefore about 37 MiB per session at the maximum size and about 13 MiB typically. + +The limit is recomputed on every resize. Exited sessions keep their screen while they keep +their history. Depth matches today's line limit, soft wraps are kept (D5), and raw history is +unchanged, so nothing MCP could read before is lost. + +### D10. Raw history stays (F16) + +`TerminalHistory` and its limits stay as they are. MCP `status`, the escape-end probe and +`read` (`mcp/tools/terminals.rs:133`, `:308`, `:522`) keep reading raw bytes with their epochs, +cursors and dropped-window reports. It also serves raw attaches. + +### D11. SIGCONT after this change + +`resume_after_lossy_attach` (`manager.rs:1880`) is sent only when an attach was answered in raw +format (an old client, or an invalid or over-budget screen) and the history is lossy (R10). Checkpoint attaches send none. Windows has no signal, as today. + +### D12. Observability (AGENTS.md profiler coverage) + +All payloads are content-free: no bytes, titles, URIs or paths. + +| Name | Kind | Payload | When | +|---|---|---|---| +| `terminal.checkpoint` | event | requested format, answered format, fallback reason (`upstream-lag`, `unsupported-state`, `tail-overflow`, `over-budget`), bytes, scrollback lines, cells, serialize µs | every attach, resync and `readCheckpoint` | +| `terminal.screen-invalidated` | event | reason | each change to invalid | +| `terminal.screen-slow` | finding | operation (`feed` or `checkpoint`), µs, cols × rows bucket | over the D4 budget | + +All are emitted through `profiler::emit` and `profiler::record_finding`, behind +`profiler::is_enabled()`. `terminal.readCheckpoint` is timed by the RPC wrapper. Documented in +`docs/skills/bibcode-profiler/references/events.md` and `findings.md`, and in the Profiler and +terminal sections of `docs/architecture/overview.md`. + +## 3. Supported-state matrix + +Kept = carried in the checkpoint. Side = carried by the side tracker. Fallback = while this +state is in use, the screen is invalid and attaches get raw history (D3). Every test named +here is a case in the differential harness unless marked Rust. + +| State | How | Proving test case | +|---|---|---| +| Cells, SGR (bold, faint, italic, inverse, 16, 256 and colon/semicolon truecolour) | Kept | `paint-sgr`, `sgr-intensity` | +| Underline style and underline colour (`4:x`, `58`) | Kept (pen patch) | `underline-style-colour` | +| OSC 8 hyperlinks | Kept (pen patch) | `osc8-runs`, `osc8-open-at-checkpoint` | +| Palette and dynamic colours (OSC 4, 10–12, 104, 110–112) | Side | `palette-restore` | +| Title (OSC 0, 2) | Side | `title` | +| Both screens, active buffer | Kept | `alt-active`, `alt-exit-after-checkpoint` | +| Saved cursors (both screens), with origin and margins | Kept | `decsc-restore-normal`, `decsc-restore-alt` | +| Scroll region, origin mode | Kept | `decstbm-origin-continuation` | +| Pending wrap (cursor and saved) | Kept by reprint | `pending-wrap-final`, `pending-wrap-saved` | +| Tab stops | Kept | `custom-tabs` | +| Charsets G0–G3, GL, DEC line drawing | Kept | `charsets-line-drawing` | +| IRM, DECAWM, LNM, DECCKM, cursor visibility | Kept | `modes-basic` | +| Bracketed paste, mouse modes, SGR mouse, focus, keypad, cursor style | Side | `modes-side-tracked`, web `focus-report-dropped` | +| Synchronized output `?2026` | Side | `sync-open-at-checkpoint` | +| Unfinished escape or control string ≤ 64 KiB | Held-back tail | `split-csi`, `split-osc`, `split-dcs`, `split-st`, `split-charset` at every byte offset | +| Unfinished string > 64 KiB | Fallback (`TailOverflow`) | Rust `tail_overflow_falls_back_then_recovers` | +| Soft wraps in scrollback and on screen | Kept | `softwrap-scrollback`, `softwrap-resize-reflow` | +| Scrollback depth (D9) | Kept | Rust `scrollback_limit_tracks_cells_budget` | +| Widths (unicode11) | Kept | `widths-corpus`, `wide-right-margin` | +| Any other OSC or DCS setter that changes later rendering (for example DECSCA, DECSLRM, `?69`) | Fallback (`UnsupportedState`) | Rust `unsupported_setter_falls_back` | +| Images (sixel, iTerm2, kitty) | Out of scope (no addon; bytes pass through as today) | none | +| Kitty keyboard flags | Out of scope | none | + +## 4. Test strategy + +Fixtures (F13): a Rust test module in `screen.rs` runs deterministic generated streams through +the real `TerminalScreen`. At each chosen boundary k (every byte for the split cases, 64 seeded +random offsets otherwise) it writes the checkpoint taken after byte k, with the stream, into +`apps/web/src/components/__fixtures__/terminal-checkpoints/*.json`. +`BIBCODE_UPDATE_SCREEN_FIXTURES=1` rewrites the files; without it the test fails on stale ones. + +Differential harness: `apps/web/src/components/terminalCheckpoint.xterm.test.ts`, using +`@xterm/headless` 6.0.0 and `@xterm/addon-unicode11` 0.9.0 as devDependencies. For each +fixture and boundary: +- terminal A takes the whole stream; +- terminal B takes RIS, then the checkpoint, then the rest of the stream; +- the harness compares every cell of both buffers (characters, width, attributes, link), + cursors, `modes`, the active buffer and the `isWrapped` flags; zero differences are allowed; +- each stream ends with a probe tail: DECRC, TAB, IND at the margin, `?1049l` or `?1047l`, and + printing at the pending wrap, so a state that looks right but is wrong shows up (F1, F3). + +Named cases, from F13 and the matrix: saved-cursor restoration; tabs and charsets; +normal-screen return; OSC 8; wrap and reflow; synchronized-output completion; startup and live +reply counts; clear, restart and exit; stale generations; upstream and downstream lag; refresh +during output; the legacy cut; requested size → output → kept size → output → attach (F7); +output → resize → output → second renderer (F5); the Unicode corpus (D2). + +| Layer | Where | Command | +|---|---|---| +| Rust unit: screen, widths, fixtures | `apps/server/src/terminal/screen.rs` | `cargo test -p bibcode-server terminal::screen` | +| Rust manager: feed and resize order, kept size, lag invalidation, clear, restart, exit, per-attachment format, grant, SIGCONT narrowing, lock benchmark | `apps/server/src/terminal/manager.rs` tests | `cargo test -p bibcode-server terminal::manager` | +| Rust RPC: payload, `readCheckpoint`, capability | `apps/server/src/production/server_terminal.rs`, `lifecycle.rs` tests | `cargo test -p bibcode-server production::server_terminal`, then `cargo test -p bibcode-server lifecycle` | +| Contracts | `packages/contracts` tests | in `packages/contracts`: `node ../../scripts/run-local-vp.mjs test run` | +| Client runtime: journal, refetch fence, cancellation, raw path unchanged | `packages/client-runtime/src/state/terminalTranscriptRuntime.test.ts` | in `packages/client-runtime`: `node ../../scripts/run-local-vp.mjs test run src/state/terminalTranscriptRuntime.test.ts` | +| Web: differential harness, reply guard, panel request field, no resize on replay | `apps/web/src/components/*.test.ts(x)` | in `apps/web`: `node ../../scripts/run-local-vp.mjs test run --project unit src/components/terminalCheckpoint.xterm.test.ts src/components/terminalReplyGuard.test.ts src/components/ThreadTerminalPanel.interactions.test.tsx` | +| Packaged E2E: a cell-diff fixture program, re-attach after the history is evicted, and a resize while the window is away (the 4-stale-rows case), with the Profiler on | `apps/desktop/e2e/specs/terminal-checkpoint.e2e.ts` | `vp run test:ui:desktop:build`, then `vp run test:ui:desktop` | + +Gates before the PR is done: `vp check`, `vp run typecheck`, `cargo fmt --all --check`, and +Clippy for the server with warnings denied. Also the Linux Docker lab and the Windows VM run of +the E2E spec (ConPTY output). + +## 5. Phases + +Every phase lands in the same pull request. Phases 0 to 4 do not touch +`ThreadTerminalPanel.tsx` and can run in parallel with other lanes. Phases 5 and 6 come after +those lanes. + +| Phase | Goal | Files | Proof | Must not touch | +|---|---|---|---|---| +| 0. Vendored avt and widths | avt 0.20.0 under `third_party/avt` with the width table, zero-width join and pen patch; width table generator | `third_party/avt/**`, root `Cargo.toml`, `Cargo.lock`, `scripts/generate-unicode11-widths.mjs` and its test, `apps/web/package.json` (devDependency on the addon, used as the table source) | Rust width tests; the table-is-current test; licence recorded in `UPSTREAM.md` (Apache-2.0) | `apps/server/src/terminal/manager.rs`, any web component | +| 1. Wrapper and serializer | `screen.rs` per D1 and D5, fixture generator | `apps/server/src/terminal/screen.rs`, `mod.rs`, fixtures | Rust continuation tests at every split; fixtures generated | manager, wire | +| 2. Differential gate | Harness and corpus green against xterm | `terminalCheckpoint.xterm.test.ts`, `apps/web/package.json` devDependencies (`@xterm/headless`, unicode11) | Zero differing cells for all cases | `ThreadTerminalPanel.tsx` | +| 3. Shadow integration | Feed and resize under the locks, reset, invalidate, profiler; nothing on the wire | `manager.rs`, profiler docs | Rust manager tests including F7 and lag; benchmark within the D4 budget; a recorded real Claude CLI stream (synthetic prompts only) causes no `terminal.screen-invalidated` | `server_terminal.rs`, contracts | +| 4. Wire and server selection | Capability, request and response fields, per-attachment format, `readCheckpoint`, startup handoff, SIGCONT narrowing, `LossyReplay` un-gated | `model.rs`, `history.rs`, `server_terminal.rs`, `lifecycle.rs`, `rpc.ts`, `contracts/src/terminal.ts`, `environment.ts`, `docs/architecture/rpc-and-orchestration.md` | Rust and contract tests; an old-client payload still gets raw; a new client on an old server decodes raw | web components | +| 5. Client | unicode11 in the panel, the request field, journal and refetch, guard and replay | `ThreadTerminalPanel.tsx`, `terminalReplyGuard.ts`, `client-runtime` state and RPC, their tests | Web and runtime tests; React review (`vercel-react-best-practices`) and `UI.md` review | server | +| 6. End to end and docs | Packaged spec on macOS, the Linux lab and the Windows VM; overview, runbooks, changelog | `apps/desktop/e2e/specs/terminal-checkpoint.e2e.ts`, `docs/architecture/overview.md`, `docs/testing/*`, `CHANGELOG.md` | The spec passes with the Profiler on; profiler data analysed | none beyond the listed files | + +**The user-visible fix arrives with Phase 5**, on top of Phase 4: the first time a client asks +for and replays checkpoints. That fixes missing characters and stale rows after a re-attach +for any program, including on Windows servers. Phase 0 must land before Phase 5, so that +widths match before any checkpoint reaches a client. + +## 6. Risks, open questions and agent's calls + +Top risks: +1. Width or grapheme mismatch with xterm on input outside the corpus. Mitigation: the D2 corpus + gate and the generated table, which follows the addon rather than `unicode-width`. +2. Lock hold time and memory across many sessions, exited ones included (D4, D9). Mitigation: + the profiler finding and the Phase 3 benchmark; stop and replan if they fail. +3. Upstream `Lagged` under bursts now that publication does more work (D3). The fallback is + safe but loses the fix for that session until a clear or restart. +4. Patch maintenance on the vendored avt (three local changes). +5. The ConPTY stream, the real Claude CLI stream, invalid UTF-8 handling (the PTY decoder + already splits at UTF-8 boundaries, `pty.rs:1038`) and resizing on the alternate screen are + not tested by the spike. Phase 6 runs the real streams. + +Open questions: (1) whether avt 0.20.0 exposes the per-line wrapped flag, a changeable +scrollback limit, and the saved-cursor and charset state, or needs more vendored patches +(settled in Phase 0; each patch is listed in `UPSTREAM.md`); (2) whether xterm emits the focus +report synchronously while parsing `?1004h` (Phase 5 test, D7); (3) whether `terminal.unicode` +needs `allowProposedApi` (Phase 2 harness, D2). + +Agent's calls: +- AC1: vendor avt with a `[patch]` (D1). AC2: extend its pen for OSC 8 and underline style and + colour instead of falling back (D1, F9). +- AC3: unicode11 over unicode-graphemes, active in every terminal; the server table is + generated from the addon (D2). +- AC4: the request value `"checkpoint-unicode11"` names the width mode; the response field is + `historyFormat` (D6). +- AC5: keep feeding an invalid screen (D3). AC6: feed under the locks, not batched (D4). +- AC7: the `UnsupportedState` rule, implemented-by-xterm setters only (D3). +- AC8: a 4 MiB checkpoint bound with scrollback trimmed first, then `OverBudget` (D6). +- AC9: `Started` and `Restarted` are re-read per checkpoint attachment in `deliver` (D6). +- AC10: a `startupQueries` field of at most 16 queries and 1 KiB as the separate handoff (D7). +- AC11: only size-ownership clients with unicode11 active ask for checkpoints (D6). +- AC12: a 1 MiB client journal, geometry-only replay of journal sizes, and a + `terminal.readCheckpoint` refetch that raises the applied floor instead of waiting (D8). +- AC13: a scrollback budget of 1.6 million cells, at most 5,000 lines (D9). +- AC14: profiler names and payloads (D12). AC15: `terminal.clear` fully resets the screen (D4). +- AC16: the exited-mode suffix stays on checkpoint replays; it reads the checkpoint's `?1047h`. +- AC17: `terminal.open` answers stay raw (D6). diff --git a/docs/plans/2026-11-13-issue-batch-348-358/triage.md b/docs/plans/2026-11-13-issue-batch-348-358/triage.md new file mode 100644 index 000000000..91987e4f7 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/triage.md @@ -0,0 +1,24 @@ +# Triage: open issues on 2026-11-13 + +Scope: all 10 open issues of this repository (the listing and the repository's open-issue count agree). Batch size: all of them, by the user's instruction of 2026-11-13. Each issue was read in full with its comments, and each item in it was checked against `main` at `a9938193b` by a read-only verifier. No tracker comment or label was posted. + +| Issue | Title | Class | Type | Recommendation | Route | Reason and evidence | Acceptance hint | Duplicate check | +|---|---|---|---|---|---|---|---|---| +| #350 | Terminal re-attach: keep a server-side screen state instead of replaying a raw byte tail | Feature | feature | now | design, then workflow | Reproduced on a real pair 5 of 5 (issue body) and 5 of 5 for the resize case (comment). Largest item; needs the approved design first | A window that re-attaches shows the same cells, buffer and modes as one that stayed attached, for any program and server platform | 3 searches; related: #348, #150 (closed, exited-shell modes) | +| #348 | AI Terminal: rendering breaks intermittently until a panel is resized | Bug without repro | bug | now | workflow | #349 fixed three causes. Two gaps remain in code (late font load, late creation) and the diagnostics the issue asks for are missing | A late font load or a late renderer creation repaints without a manual resize; a content-free size record exists in development | 3 searches; related: #350, #284 (closed) | +| #351 | Windows: expanding a reasoning row scrolls the chat list to its end | Bug with repro | bug | now | diagnose, then workflow | Measured in a Windows VM; fails on `main`. Three candidate code paths, each testable without Windows | Toggling a reasoning row that is not the turn's last row makes no scroll; the packaged spec passes on Windows | 3 searches; related: #173, #228 (closed) | +| #352 | Flaky under load: removal_refuses_when_an_admission_does_not_drain_before_the_deadline | Chore | test | now | workflow | The 2 s bound is the test's own guard around the whole removal, not a production bound | 50 of 50 runs alone under load and 5 of 5 whole-suite runs in the Windows VM | 3 searches, none | +| #355 | Pull-request lookups: teardown, failures and stale answers | Bug with repro | bug | now | workflow | 4 of 5 items confirmed in code; 1 decided by a stream test | Quiesce waits for lookups; a failing lookup is not retried for 5 s; a remembered request expires after 5 min | 3 searches; related: #345 (closed) | +| #356 | Worktree creation records: delete by id, audit rollbacks, and edge cases | Bug with repro | bug | now | workflow | 3 items confirmed and fixable; 2 are limits to record | A record of a worktree recreated at the same path survives the old one's removal; rollbacks reach the audit | 3 searches; related: #344 (closed) | +| #357 | Desktop update install: the leave guard after a reload or a lost event | Bug with repro | bug | now | workflow | 4 items confirmed in code; 1 decided by a DOM test | After a reload during an install the block is back; the dialog closes when the install has ended even without the event | 3 searches, none | +| #358 | Web and terminal: native-preview cover for popups, a size-claim capability, and lab observations | Bug with repro | bug | now | workflow, with bounded probes | 5 items have a code cause; 4 lab observations get a bounded probe each | A popup hides a native preview only where they overlap; read-only Markdown loads relative images | 3 searches; related: #345 (closed) | +| #353 | Follow-ups from the #349 validation rounds | Chore | bug and measurement | now | workflow, with bounded probes | 4 items confirmed; 3 decided by a probe; 1 covered by #350's width work | A second AppImage mount is hidden; the e2e harness recovers per test; Fit says why nothing was sent | 3 searches; related: #350 | +| #354 | Git: lock handling for pull and hooks, sign-in detector, and two missing doc entries | Chore | bug and docs | now | workflow | 1 code fix, 2 doc gaps, 2 limits to record (the ownership rule of #349 already covers hooks on Linux and macOS) | Sign-in guidance appears only for Git's own `fatal:` line; both docs list the missing entries | 3 searches; related: #341, #343 (closed) | + +Totals: now 10, next 0, defer 0, proposed for closing 0. + +Not triaged fully: + +- #348: the exact visual symptom is still unknown (no screenshot or recording). The two gaps found are real in code, but no run ties them to the report. +- #350: whether the real Claude CLI shows the stale rows after a resize is not known; the measurement used a stand-in. +- Four lab observations in #353 and #358 (stalls and memory on Linux, the WebGL fallback's first cause, the over-tall grid, the one-off pairing refusal) have no reproduction. Each gets one bounded probe; with no signal, the result is recorded and nothing is changed. diff --git a/docs/plans/2026-11-13-terminal-screen-350/reviews/design-review-1.md b/docs/plans/2026-11-13-terminal-screen-350/reviews/design-review-1.md new file mode 100644 index 000000000..0877249ab --- /dev/null +++ b/docs/plans/2026-11-13-terminal-screen-350/reviews/design-review-1.md @@ -0,0 +1,60 @@ +Verdict: **Needs rework**. The overall approach is sound, but the proposed protocol can lose live output, erase a restored checkpoint during startup handoff, and mix events from different process generations. D5 also omits terminal state needed for continuation; read-only probes against installed xterm 6 reproduced several failures. + +Locations below use **D** = [terminal-screen-design.md](docs/plans/2026-11-13-issue-batch-348-358/terminal-screen-design.md), **S** = [spec.md](docs/plans/2026-11-13-issue-batch-348-358/spec.md). `ESC` means byte `0x1b`. + +Findings, most severe first: + +| # | Severity | Location (file:line) | Issue | Evidence | Fix | +|---|---|---|---|---|---| +| 1 | Blocker | D:266–279 | Raising the applied floor can discard events the live renderer still needs. **F5, F15.** | Suppose the renderer has applied sequence 10, while `readCheckpoint` returns S=13 covering output 11, resize 12 and exit 13. D8 keeps the live renderer unchanged, then drops those arriving events. The current runtime applies output, size and lifecycle through separate paths (`packages/client-runtime/src/state/terminalTranscriptRuntime.ts:304`, `:320`, `:324`, `:336`). A cached checkpoint does not update that renderer or its metadata. | Either atomically apply the snapshot and post-S journal to existing renderers, including size/status, or keep cache-refresh fences separate from live-delivery fences. Test a response overtaking all three event types. | +| 2 | Major | D:230–246 | `replay(startupQueries, true)` clears the checkpoint again. **F2, F4.** | Every `replay()` calls `writeTerminalBuffer`, which first writes RIS (`apps/web/src/components/terminalReplyGuard.ts:111`, `:51`). A probe restored the cursor to row 3, column 4, then used the proposed startup replay: DSR answered **1;1**, and the restored screen was erased. It also destroys any held-back parser prefix. | Add a separately guarded query operation that does not reset the terminal. Specify how it preserves unfinished parser input and reaches only the grant-winning renderer once. Define overflow behavior for the 16-query/1-KiB cap. | +| 3 | Major | D:216 | Re-reading lifecycle events is not a generation-fenced resync. **F4, F6, F15.** | While delivering queued `Restarted(g2)`, the current session may already be g3. Replacing its snapshot with g3 and advancing the cursor leaves queued g2 events behind; output has no generation (`apps/server/src/terminal/model.rs:319`), and `accept()` filters it by sequence (`manager.rs:4901`). Restart events bypass that sequence filter (`:4909`). Moreover, surviving claimed streams consume the new generation’s grant (`:2847`): queries swallowed by the replacement checkpoint can lose their live handoff. `Started` is also absent from the attach-stream union (`packages/contracts/src/terminal.ts:466`). | Specify one atomic, generation-aware catch-up operation: settle replacement, drain/fence covered events, select the snapshot, and preserve unanswered queries. Convert `Started` to an accepted snapshot shape or extend the contract explicitly. | +| 4 | Major | D:184–193 | Saved-origin restoration can leave the **current** origin mode enabled when it should be disabled. **F3.** | Step 6 can enable origin mode for constructing saved state; step 9 only describes enabling it. Prefix `ESC[2;4r ESC[?6h ESC[2;2H ESC7 ESC[?6l ESC[1;1H`, then checkpoint and `X`, should print on row 1. A probe following the stated restoration steps printed on row 2. xterm’s `?6h` homes the cursor (`apps/web/node_modules/@xterm/xterm/src/common/InputHandler.ts:1901`). | Restore both on **and off** states explicitly. Return to neutral origin/margins before constructing another buffer, then restore final margins, origin and cursor. | +| 5 | Major | D:184–188; D:339–343 | Inactive alternate-buffer saved state and saved charsets are omitted. **F1.** | Step 7 constructs alternate saved state only when alternate is active. Prefix `ESC[?1047h ESC[3;4H ESC7 ESC[?1047l`, checkpoint, then `ESC[?1047h ESC8 X` must print at row 3, column 4; omission prints at 1;1. Separately, `ESC(0 ESC7 ESC(B`, checkpoint, then `ESC8q` must print `─`, not `q`. Both failures reproduced. xterm saves/restores a charset with the cursor (`…/InputHandler.ts:2898`, `:2916`). | Serialize saved state for both buffers regardless of the active buffer, including the charset xterm restores. Add both exact continuations to the gate. | +| 6 | Major | D:191–196; D:347 | Holding unfinished escapes does not preserve all parser continuation state. **F1, F13.** | Prefix `A`, checkpoint, continuation `ESC[3b` should yield `AAAA`. D5’s trailing positioning/SGR commands clear xterm’s preceding-character state, yielding only `A`; reproduced. REP reads that state (`…/InputHandler.ts:1615`), and CSI dispatch clears it (`…/parser/EscapeSequenceParser.ts:691`). No unfinished escape exists at this cut, so the held tail does not help. | Include preceding-print/join state in the continuation design, or retain a replay suffix that reconstructs it after all state-setting commands. Test REP and combining continuation at printable-character boundaries. | +| 7 | Major | D:83–97 | A width table plus “join the previous cell” does not reproduce xterm’s placement behavior. **F9.** | Unicode11’s joining depends on preceding parser state, not only `wcwidth`. `A ESC[31m U+0301 B` differs from `A U+0301 B`; the installed xterm probe confirmed this distinction. Also, with four columns, `ABC ESC[?7l 界` leaves `ABC` in xterm (`…/InputHandler.ts:591`); avt’s unpatched right-edge path backs up and attempts to write the wide character (`avt-0.20.0/src/engine.rs:876`). The three named patches do not specify this correction. | Port the provider’s contextual joining and xterm’s margin behavior, including DECAWM off, wide-cell tails, cursor movement and control boundaries. Keep ZWJ/VS16 behavior explicitly non-grapheme-based. | +| 8 | Major | D:116–124; S:90 | Clear/RIS does not establish every tracked state afresh. **F8, F9.** | RIS resets buffers and core modes, but xterm’s input-handler reset resets attributes only; it does not reset its stored title (`…/InputHandler.ts:3324`, `:3330`, `:2941`). A probe confirmed `ESC]2;kept BEL ESCc` retains that title. If an upstream gap lost a title setter, RIS cannot recover it. `terminal.clear` currently publishes a clear event rather than obtaining missing state (`apps/server/src/terminal/manager.rs:4097`). | Define validity per affected state, or require an actual recovery boundary for everything claimed authoritative. The proposed RIS gate must compare title/palette and other side state, not just cells and modes; apply the same reasoning to clear. | +| 9 | Major | D:140–156; D:286–297 | The cell budget does not bound patched memory, and throughput is not a lock-time bound. **F11.** | Joining arbitrarily many combining marks into one cell requires variable storage; hyperlink targets and their registry also need retention limits. A 1.6-million-cell limit bounds neither. Current avt cells contain one `char` and a fixed pen (`avt-0.20.0/src/cell.rs:4`), so the pre-patch memory estimate cannot establish the patched budget. Parsing/serialization under publication locks blocks the publisher that drains the 1024-read channel (`manager.rs:3389`; `pty.rs:598`). | Budget combined text, links, auxiliary tables and snapshot copies, with explicit exhaustion behavior. Measure publication-lock hold time, input latency and upstream lag under bursts and concurrent attaches across retained/exited sessions. Treat increased lag as a risk until measured, not an observed result. | +| 10 | Major | D:216–217 | The existing resync queue safeguard does not cover replacement `Started`/`Restarted` checkpoints. **F11.** | The capacity wait applies only to `AttachmentItem::Resync` (`apps/server/src/production/server_terminal.rs:950`, `:966`). Event byte accounting counts only output; snapshot history is counted only in the Resync arm (`:1158`). Returning a large replacement snapshot as `Event(Restarted)` bypasses both controls. | Count and budget snapshot bytes for every variant and initial response. Apply checkpoint backpressure independently of whether the delivery is labelled an event or resync. | +| 11 | Major | S:72–75 | R1 requires a mirror to call `fit()`, which can change its authoritative grid. **F14.** | Real FitAddon calls `terminal.resize()` when its proposal differs (`apps/web/node_modules/@xterm/addon-fit/src/FitAddon.ts:35`). A smaller mirror would therefore reflow at its local size without any server event. Current authoritative sizing comes from `applySize` (`ThreadTerminalPanel.tsx:1905`). The mock normally makes `fit()` non-mutating (`ThreadTerminalPanel.interactions.test.tsx:159`, `:1301`), so the requested test can pass while missing this corruption. | Require remeasurement/repaint while preserving the server grid on mirrors. Enable realistic fit mutation in the test and assert the grid remains authoritative, alongside “no size claim.” | +| 12 | Major | S:175 | Treating a failed install-state query as “not installing” defeats the reload protection requirement. | Concrete case: reload during an allowed install, state query fails, editing becomes available, then installation restarts the host. The current guard deliberately holds edits until installation ends (`apps/web/src/components/files/unsavedFileEdits.ts:30`, `:83`); installation proceeds after permission without asking the reloaded renderer again (`apps/desktop/src-tauri/src/remote_update_delegate.rs:233`). | Distinguish unsupported querying from a transient unknown result. Preserve protection while the result is unknown and retry; only a confirmed ended state should release a known install block. | +| 13 | Minor | S:119 | A Linux reproduction at 1000×720 does not establish that the viewport is the cause. | It establishes that WebView2 is unnecessary. The shared toggle and growth-follow paths remain possible causes (`apps/web/src/components/ChatView.tsx:4173`, `:4333`). The packaged scenario explicitly uses programmatic scrolling (`apps/desktop/e2e/specs/chat-reasoning-rows.e2e.ts:269`). | Say “platform-independent reproduction,” then vary viewport independently before attributing causation. Keep the planned decision-path instrumentation. | + +**Coverage, in F1–F16 order:** F1 weak (#5–6); F2 addressed in isolation but broken by D7 (#2); F3 weak (#4); F4 weak (#2–3); F5 weak (#1); F6 substantially addressed, with the lifecycle delivery gap in #3; F7 addressed; F8 weak (#8); F9 weak (#7–8 and the API limits below); F10 addressed conditionally on serializer/reflow proof; F11 weak (#9–10); F12 addressed by retaining a rolling emulator; F13 weak because the proposed corpus misses the reproduced continuations; F14 weak through R1 (#11); F15 sound at the local publication boundary but broken by D6/D8; F16 addressed. + +What the design got right (so a revision does not break it). + +- **The published-byte boundary is correct.** Redaction happens before history insertion and sequence advancement (`manager.rs:3395`). A redactor’s withheld prefix has not yet been published or assigned an output sequence (`:554`); its eventual flush goes through `record_private_output` (`:1963`, `:1973`). Feeding those paths before advancing, and including the emulator’s separate held escape tail exactly once, can make checkpoint S cover every published byte through S. It should **not** flush unpublished redactor input merely to take a checkpoint. +- **The resize hooks are correctly identified:** requested dimensions and retained-size correction both matter (`manager.rs:3952`, `:3993`). Ownership notifications and acknowledgements spend sequences without necessarily changing geometry (`:1823`, `:3938`). +- **Negotiation protects legacy clients.** Optional request/response fields, raw history by default, and per-attachment selection are the right approach. The current Rust attach payload accepts unknown fields (`server_terminal.rs:1677`), and absent capabilities decode false (`packages/contracts/src/environment.ts:74`). Actual mixed-version execution is still required. +- **No inherent publication-lock deadlock is established by D6.** `recv()` invokes `deliver()` after receiving the broadcast (`manager.rs:4823`). A correctly implemented publication→session acquisition can work. However, today’s resync uses operation→session locks (`:4941`, `:4951`), so “under the same locks” is inaccurate and insufficient: the revised algorithm must specify generation capture, rechecking and ordering without holding session while awaiting publication. +- **Raw operational history should remain separate.** Its byte cursors, epochs and dropped-window reporting are independent of visual checkpoint retention (`history.rs:228`, `:302`, `:325`). +- **Profiler direction is appropriate.** D12 uses content-free fields, enabled checks and the existing RPC wrapper, matching `AGENTS.md:145–163`. It needs the lock/lag/resource evidence described above. No new child process or service is proposed for #350. +- Outside the findings above, I found no additional concrete premise defect in **R2–R4, R13–R34 or R36–R47**. This is a source-review conclusion, not evidence that their acceptance tests pass. + +Open questions the design cannot answer. + +Some of D’s API questions can already be resolved from the cached, pinned **avt 0.20.0** source: + +| API question | Verified answer | +|---|---| +| Emulator type | It exports `avt::Terminal`, **not `avt::Vt`** (`avt-0.20.0/src/lib.rs:89`). The published [0.20.0 documentation](https://docs.rs/crate/avt/0.20.0) agrees. | +| Per-line wrap flag | Public `Line::is_wrapped()` exists; it means this row continues **onto the next row** (`src/line.rs:568`). Account for that convention when comparing xterm flags. | +| Saved cursor and charsets | Private engine state; the saved cursor structure lacks a charset, and the engine has two charset slots (`src/engine.rs:37`, `:102`). D’s G0–G3 claim needs additional implementation or fallback. | +| Scrollback limit | Public construction-time `with_scrollback` exists (`src/lib.rs:106`); the reviewed public surface has no limit setter. D9’s resize-dependent limit needs a specified implementation. | +| Dump | Public, and includes pending parsing, but explicitly omits retained history and targets a fresh avt terminal (`src/lib.rs:294`). It is not a ready-made xterm checkpoint. | +| Pen extension | Feasible through vendoring, but changing pen fields alone does not implement parsing, link ownership, combined-cell storage and serialization (`src/pen.rs:4`; `src/cell.rs:4`). | + +Thus vendoring is feasible, but **the claimed three-patch scope and resulting fidelity remain unverified**. The existing Cargo patch/provenance pattern is suitable (`Cargo.toml:156`; `third_party/portable-pty/UPSTREAM.md:1`). + +Two other questions are already answerable: browser xterm’s `unicode` getter checks proposed-API permission (`apps/web/node_modules/@xterm/xterm/src/browser/public/Terminal.ts:91`), and restoring focus reporting synchronously reaches `_reportFocus()` (`…/CoreBrowserTerminal.ts:165`, `:1293`). Keep integration tests, but these need not remain unknown design premises. + +Still unresolved: + +- Can the revised, fully patched serializer pass continuation/reflow tests and memory/latency budgets on Windows and the other supported platforms? +- What request identity fences a cancelled refetch after **clear within the same process generation**, and how does a later renderer restart a cancelled refresh? +- How are real CLI captures isolated and sanitized? Synthetic prompts alone do not exclude private paths, titles or links; committed fixtures must satisfy `AGENTS.md:87–104`. + +Verification was read-only source inspection and in-memory Node/xterm probes. No Rust build, full suite, Unicode11 differential harness, native UI or load benchmark ran. CodeGraph sync was skipped because writes were prohibited. Final Git checks showed the same pre-existing untracked plan directory and no tracked diff; no files were changed. + + From c0f9aa4fff2c98bf194362d402d91d637e56461e Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:08:35 -0300 Subject: [PATCH 002/115] docs(plan): rework the issue-batch plan after its first Codex review Co-Authored-By: Claude Fable 5.2 --- .../execute-plan.md | 4 +- .../phases/PHASE-01-terminal-panel-fixes.md | 407 ++++++++++++------ .../PHASE-02-git-and-pull-request-lookups.md | 126 +++--- ...SE-03-worktree-records-and-removal-test.md | 57 ++- .../phases/PHASE-04-update-install-guard.md | 49 ++- ...E-05-reasoning-row-scroll-and-dashboard.md | 85 ++-- ...SE-06-popups-picker-and-markdown-images.md | 60 +-- .../PHASE-07-server-odds-and-e2e-harness.md | 48 ++- .../phases/PHASE-08-labs-and-probes.md | 76 ++-- .../PHASE-09-docs-gates-and-pull-request.md | 10 +- .../2026-11-13-issue-batch-348-358/plan.md | 26 +- .../progress.md | 5 +- .../reviews/plan-review-1.md | 27 ++ .../2026-11-13-issue-batch-348-358/spec.md | 6 +- 14 files changed, 598 insertions(+), 388 deletions(-) diff --git a/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md b/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md index 8fd4ce601..11ebedd81 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/execute-plan.md @@ -30,9 +30,9 @@ Before any other work, ask the user these questions in one message, using the ho 2. Which reviewer model and effort? I recommend `gpt-6.1-sol` and `high` for Codex reviewers (the plan review used them), `opus` and `medium` for Claude reviewers. 3. May this run commit each finished task to the branch in `progress.md`'s `Branch` line, or, when there is none, use the existing branch `claude/open-issues-pr-dc3c33` (created from `main` for this batch) and commit to it? Pushing and opening a pull request happen only when the user asks. -## Before round 1 (plan decision P3) +## Standing rules for this repository -Run the packaged `chat-reasoning-rows` spec on the base commit in the Linux lab at a 1000x720 viewport, with the Profiler on, and log the result in `progress.md`. Task 5.1 reads it. Follow the user's standing rules for this repository: subagents run on `sonnet` or `haiku`, or `opus` at effort medium for complex work, never above; check free disk (at least 60 GB) before each round of Rust writers and keep at most four at once; every review is triaged with the advisor; push once, after the gates pass. +Subagents run on `sonnet` or `haiku`, or `opus` at effort medium for complex work, never above. Check free disk (at least 60 GB) before each round of Rust writers and keep at most four at once. Every review is triaged with the advisor. Push once, after the gates pass. Task 8.3 step 1 (the Windows trace run) starts as soon as Task 5.1 asks for it. ## 3. Workflow execution diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md index 974996cac..510499ea4 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md @@ -4,14 +4,30 @@ - Round: 1 - Depends on: none - Owns: `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`, `apps/web/src/components/terminalSizing.ts`, `apps/web/src/components/terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts`, `apps/web/src/components/terminalSizeDiagnostics.ts` (new), `apps/web/src/components/terminalSizeDiagnostics.test.ts` (new), `apps/web/src/components/terminalUnicode.ts` (new), `apps/web/src/components/terminalUnicode.xterm.test.ts` (new), `apps/web/package.json`, `pnpm-lock.yaml` (updated by the executor's install only), `packages/contracts/src/environment.ts`, `packages/contracts/src/environment.test.ts`, `packages/contracts/fixtures/**` (regenerated by `check:contracts` only), `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts` -- Must not touch: `apps/web/src/components/terminalReplyGuard.ts`, `packages/client-runtime/**`, `apps/server/src/terminal/**` (the #350 phases own them), `apps/web/src/components/ChatView.tsx`, and every doc under `docs/` (Phase 9 owns doc edits) +- Must not touch: `apps/web/src/components/terminalReplyGuard.ts`, `packages/client-runtime/**`, `apps/server/src/terminal/**` (the separate #350 plan owns them), `apps/web/src/components/ChatView.tsx`, and every doc under `docs/` (Phase 9 owns doc edits) - Skills: `tdd` (each behavior change is red first), `vercel-react-best-practices` (required review of every change to the panel's effects and hooks), `mstack-diagnose` (R16 and R43 are decided by tests) - Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data); `UI.md` (the Fit notice wording and its status region); `docs/architecture/overview.md`, the terminal size ownership paragraphs around "Resize requests publish `resized { size }`" (owner, mirror and hand-over rules this phase must keep); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (R1 to R5, R16, R17, R41 to R43, D4, D12, D13) -- Checkpoint: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx src/components/terminalSizeDiagnostics.test.ts src/components/terminalUnicode.xterm.test.ts src/components/terminalSizePolicy.test.ts src/components/terminalSizing.test.ts src/lib/terminalFont.test.ts` exits 0. Then `vp test run packages/contracts/src/environment.test.ts`, `vp run check:contracts`, and `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exit 0. Last, `vp check`, `vp run typecheck`, `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0. +- Checkpoint: From `apps/web`: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx src/components/ThreadTerminalPanel.test.tsx src/components/terminalSizeDiagnostics.test.ts src/components/terminalUnicode.xterm.test.ts src/components/terminalSizePolicy.test.ts src/components/terminalSizing.test.ts src/lib/terminalFont.test.ts` exits 0; From the repository root: `vp test run packages/contracts/src/environment.test.ts`, `vp run check:contracts` and `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exit 0; From the repository root: `vp check`, `vp run typecheck`, `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0. -Every task edits `ThreadTerminalPanel.tsx` or its interaction test, so tasks run one after another in the listed order. The separate #350 plan edits the same file after this phase. Rust commands use the pinned toolchain (1.98.1) first on PATH, with `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`. Web test commands below are run from `apps/web`. +Every task edits `ThreadTerminalPanel.tsx` or its interaction test, so tasks run one after another in the listed order. The separate #350 plan edits the same file after this phase. -Profiler coverage line for the final report: none applies. This phase adds no child process, no RPC method, no worktree removal, no binary mode, no shutdown step and no profiler event. The R4 record is a development-only in-memory ring, not a profiler record. R42 changes one existing `console.warn` in the browser, and that warning carries no terminal content. +Rust commands use the pinned toolchain (1.98.1) first on PATH, with `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`, from the repository root. Every `vp test run --project unit` command below runs from `apps/web`. Contract and `cargo` commands run from the repository root. + +The interaction test (`ThreadTerminalPanel.interactions.test.tsx`) replaces these modules at file level: +- `@xterm/xterm`, with a fake `Terminal` (24 × 80, recording `loadAddon`, `refresh`, `clearTextureAtlas`, `resize`); +- `@xterm/addon-fit`, whose `fit` resizes the fake grid only while `xtermState.fitAppliesProposal` is `true` (default `false`) and whose `proposeDimensions` returns `xtermState.proposedDimensions`; +- `@xterm/addon-webgl` and the loader in `./terminalWebgl`; +- `../lib/terminalFont`, where only `ensureBundledTerminalFontLoaded` becomes `testState.fontLoad`; +- `./activity/ActivityPaneBinding`, `../state/terminal` and other state modules. + +`TerminalViewport` itself and `terminalSizing`/`terminalSizePolicy` stay real. The file controls time in three ways: +- animation frames are queued and run by `view.flushFrame()`; +- 30 ms component timers are captured and run by `view.flushMountFit()`; +- `performance.now` is offset by `settleClockOffset` (`elapseMountSettle()`). + +Other timers are real unless a test calls `vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })`. Each task below says which of these it uses. + +Profiler coverage line for the final report: none applies. This phase adds no child process, no RPC method, no worktree removal, no binary mode, no shutdown step and no profiler event. The R4 record is a development-only in-memory ring, not a profiler record. R42 changes one existing `console.warn` in the browser, and that warning carries a failure stage and an error type name, never an error message or terminal content. ## Tasks @@ -20,75 +36,98 @@ Profiler coverage line for the final report: none applies. This phase adds no ch - Satisfies: R1. Given a rendered terminal with an unchanged mount size, when `document.fonts` dispatches `loadingdone`, then the terminal is re-measured and `clearTextureAtlas` and `refresh` each run once. An owner refits. A mirror keeps the server's columns and rows and sends no size claim. The owner, mirror and legacy assertions equal those of the existing device-pixel-ratio test. Given the terminal is unmounted, nothing runs. - Depends on: none - Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes `scheduleResize`, the callback the pixel-ratio change already uses. It never calls `fit` directly: it runs `rendererRef.current?.size("layout")` in a frame and then the 50 ms `clearTextureAtlas` and `refresh`. `size("layout")` is `handleSizeTrigger("layout")`: - - Without size ownership (legacy) it calls `fitTerminalSafely` and sends the fitted size. - - With ownership it only re-measures through `updateFittedDimensions` (the fit addon's `proposeDimensions`, which does not resize xterm). An owner then claims, and xterm is resized from the ordered `resized` event. A mirror only updates its scale through `updateSizeNotice`. +- Interfaces: consumes `scheduleResize`, the function inside the mount-observer effect (the effect with `new ResizeObserver(scheduleResize)`). Its existing callers are: + - the `ResizeObserver`; + - the `CENTER_PANEL_RESIZE_END_EVENT` listener on the closest `[data-center-panel-workspace]`; + - `handlePixelRatioChange`. + + `scheduleResize` never calls `fit` itself. It returns early while `isCenterPanelResizing(container)` is true; the drag-end event runs it again afterwards. It runs `rendererRef.current?.size("layout")` in a frame and then, 50 ms later, `terminal.clearTextureAtlas()` and `terminal.refresh(0, terminal.rows - 1)`. `size` is `handleSizeTrigger`: + - With `sizeOwnership` false (legacy), `"layout"` calls `fitTerminalSafely(fitAddon)` and `requestTerminalResize`. + - With ownership, it only re-measures through `updateFittedDimensions` (`proposeTerminalDimensions`, which does not resize xterm). An owner then claims, and xterm is resized by `applySize` from the ordered `resized` signal. A mirror only updates its scale through `updateSizeNotice`. Produces a `loadingdone` listener on `document.fonts` that calls `scheduleResize`, removed in the same effect's cleanup. - Skills: tdd (red-green for the new listener); vercel-react-best-practices (effect cleanup and dependency review) -- Read first: `ThreadTerminalPanel.tsx` (the effect that creates `new ResizeObserver(scheduleResize)`); the test "refits when the device pixel ratio changes under an unchanged mount" (its `vi.stubGlobal("matchMedia", …)` pattern) +- Read first: `ThreadTerminalPanel.tsx` (the mount-observer effect and `handleSizeTrigger`); the test "refits when the device pixel ratio changes under an unchanged mount" (its own `fit.mockImplementation`, the owner and mirror snapshots and its fake timers) - Tier: standard - TDD: red-green -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"` exits 0 with every case passing. +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"` exits 0 and runs four tests (three modes plus the unmount test). Steps -1. [ ] Next to "refits when the device pixel ratio changes under an unchanged mount", write `it.each(["legacy", "owner", "mirror"] as const)("re-measures and repaints when a font finishes loading under an unchanged mount (%s)")` in `ThreadTerminalPanel.interactions.test.tsx`. Copy that test's setup: - - a fit mock that really resizes the fake grid (`terminal.cols` and `terminal.rows` take `xtermState.proposedDimensions`), as the real fit addon does; +1. [ ] Next to "refits when the device pixel ratio changes under an unchanged mount", write `it.each(["legacy", "owner", "mirror"] as const)("re-measures and repaints when a font finishes loading under an unchanged mount (%s)")`. It is a new test using the real `TerminalViewport` and the file's fakes. Copy the pixel-ratio test's setup exactly: + - `fit.mockImplementation(() => { terminal.cols = proposed.cols; terminal.rows = proposed.rows; })`, so `fit` really resizes the grid as the real fit addon does; - the snapshot sized 80 × 24 with `sizeClaim` `null` for owner and `"other-window"` for mirror; - - the owner's acknowledging `resized`. + - the owner's acknowledging `resized` with its claim read from `view.resizeSpy`. + + Stub `document.fonts` with `vi.stubGlobal` or `Object.defineProperty(document, "fonts", …)`: an `EventTarget` with `status: "loaded"`. Do it before mounting, so the effect subscribes to it. Then: + - set `xtermState.proposedDimensions = { cols: 80, rows: 23 }` and clear the spies; + - call `vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })`, dispatch `new Event("loadingdone")` inside `act`, run `view.flushFrame()`, then `act(() => vi.advanceTimersByTime(50))`; + - call `vi.useRealTimers()` in `finally`. - Stub `document.fonts` with an `EventTarget` that also has `status: "loaded"`. Set `xtermState.proposedDimensions = { cols: 80, rows: 23 }`, dispatch `new Event("loadingdone")`, flush the frame and advance 50 ms. Assert exactly what the pixel-ratio test asserts: + Assert exactly what the pixel-ratio test asserts: - legacy: `fit` called once, `terminal.rows` is 23, and a resize to 80 × 23 with no `sizeClaim` was sent; - owner: a resize to 80 × 23 with its `sizeClaim` was sent, and after the `resized` event `terminal.rows` is 23; - mirror: `fit` not called, `resizeSpy` not called, and `terminal.rows` still 24. - In every mode, `clearTextureAtlas` ran once and `refresh` was called with `(0, terminal.rows - 1)`. Add a second test, "does nothing when a font finishes loading after unmount": unmount, dispatch, and assert that `fit`, `clearTextureAtlas`, `refresh` and `resizeSpy` were not called. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"`. Expected: FAIL in all three modes, `clearTextureAtlas` called 0 times. The mirror case must not fail on `fit`; if it does, the test setup differs from the pixel-ratio test. -3. [ ] In the mount-observer effect, add `document.fonts?.addEventListener("loadingdone", scheduleResize)` and the matching `removeEventListener` in its cleanup. Guard with `typeof document.fonts?.addEventListener === "function"`. Do not call `fitAddon.fit()` or `fitTerminalSafely` from the listener: a mirror's grid belongs to the server's size. -4. [ ] Run the same command. Expected: PASS. -5. [ ] Prove the test can fail: comment out the `addEventListener` line, then run the command. Expected: FAIL. Restore it and run again. Expected: PASS. -6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. + In every mode, `clearTextureAtlas` ran once and `refresh` was called with `(0, terminal.rows - 1)`. Write `it("does nothing when a font finishes loading after unmount")`: mount, flush, `await unmount(view.mounted)`, clear the spies, dispatch `loadingdone`, flush, and assert that `fit`, `clearTextureAtlas`, `refresh` and `resizeSpy` were not called. +2. [ ] Run the Done-when command. Expected: FAIL in the three mode cases with `clearTextureAtlas` called 0 times. The mirror case must fail only on `clearTextureAtlas` and `refresh`, never on `fit`; a failure on `fit` means the setup differs from the pixel-ratio test. The unmount test passes. +3. [ ] In the mount-observer effect, add `document.fonts?.addEventListener("loadingdone", scheduleResize)` and the matching `removeEventListener` in its cleanup. Guard with `typeof document.fonts?.addEventListener === "function"`. Do not call `fitAddon.fit()` or `fitTerminalSafely` from the listener. +4. [ ] Run the Done-when command. Expected: PASS, 4 tests. +5. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand (`git stash` is not allowed; delete the two added lines) and rerun. Expected: FAIL. Restore the change and rerun. Expected: PASS. +6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.2: Repaint a renderer created after host configuration - Satisfies: R2. Given an AI terminal waiting for host configuration, when configuration arrives and the pending font load then resolves, then `clearTextureAtlas` and `refresh` run on the new renderer and exactly one resize is issued for the initial geometry. A font callback for a disposed renderer does nothing. - Depends on: 1.1 - Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes `ensureBundledTerminalFontLoaded(): Promise` from `apps/web/src/lib/terminalFont.ts` (unchanged), `rendererEpoch`, `rendererEpochRef` and `resizeRendererGenerationRef`. Produces a font effect whose dependency list is `[shouldRender, terminalFontFamily, rendererEpoch]`, with the stale-epoch guard `rendererEpoch !== rendererEpochRef.current` (the guard the WebGL effect already uses). +- Interfaces: consumes `ensureBundledTerminalFontLoaded(): Promise` from `apps/web/src/lib/terminalFont.ts`. Its only caller in the panel is the font effect. The interaction test replaces it with `testState.fontLoad`; that is the seam this task needs, because the real loader has its own tests in `terminalFont.test.ts`. + + Also consumes: + - `rendererEpoch` and `rendererEpochRef` (bumped in the terminal creation effect next to `setRendererEpoch`); + - `resizeRendererGenerationRef`; + - `canAttachTerminal` (`command === undefined || hasAuthoritativeHostConfig`), which gates the creation effect. + + Font writers: the creation effect passes `fontFamily: readTerminalFontFamily()`, and the font effect assigns `terminal.options.fontFamily`. Produces a font effect whose dependency list is `[shouldRender, terminalFontFamily, rendererEpoch]`, with the early return `rendererEpoch !== rendererEpochRef.current` (the guard the WebGL effect already uses). - Skills: tdd (red-green for the new dependency); vercel-react-best-practices (effect dependency and stale-closure review) -- Read first: `ThreadTerminalPanel.tsx` (the effect that calls `ensureBundledTerminalFontLoaded()`, and the WebGL effect's epoch guard); the test "waits for authoritative host config before attaching Codex"; the test "issues one resize for the initial geometry and skips unchanged fits from either path" +- Read first: `ThreadTerminalPanel.tsx` (the font effect, the creation effect and the WebGL effect's epoch guard); the tests "waits for authoritative host config before attaching Codex" and "issues one resize for the initial geometry and skips unchanged fits from either path" - Tier: standard - TDD: red-green -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "host config"` exits 0, and so does the same command with `-t "issues one resize for the initial geometry"`. +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "host config"` exits 0 (the two new tests and "waits for authoritative host config before attaching Codex"), and so does the same command with `-t "issues one resize for the initial geometry"`. Steps -1. [ ] Write `it("repaints a renderer created after host config once the bundled font loads")` next to "waits for authoritative host config before attaching Codex". Set `testState.serverConfig = null`. Make `testState.fontLoad` return a deferred promise. Mount a Codex viewport, then deliver the config. Resolve the font promise and flush the frame and timers. Assert `clearTextureAtlas` once and `refresh(0, rows - 1)` on the new renderer, and `resizeSpy` called exactly once. Add `it("ignores a font load that resolves after its renderer was disposed")`: hide the viewport before resolving, then assert neither call ran on the disposed terminal. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "host config"`. Expected: FAIL, `clearTextureAtlas` called 0 times. -3. [ ] Add `rendererEpoch` to the font effect's dependency list. Return early when `rendererEpoch !== rendererEpochRef.current`. Keep the existing generation and identity guards in the `.then` callback. -4. [ ] Run the step 2 command, then the same command with `-t "issues one resize for the initial geometry"`. Expected: both PASS. -5. [ ] Prove the test can fail: remove `rendererEpoch` from the dependency list, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. -6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] Write two new tests next to "waits for authoritative host config before attaching Codex", built like it (`testState.serverConfig = null`, a Codex `command`, the real `TerminalViewport`). In both, make `testState.fontLoad` return a promise whose `resolve` the test keeps. Expected: the change is in place, and the next command step checks it. + - `it("repaints a renderer created after host config once the bundled font loads")`: mount, clear `resizeSpy`, then deliver the config with `mounted.root.render(...)` inside `act`. Run `view.flushFrame()` and `view.flushMountFit()`. Resolve the font promise inside `act(async () => { resolve(); await Promise.resolve(); })`. Assert on the new fake terminal (`xtermState.terminals.at(-1)`): `clearTextureAtlas` once and `refresh(0, rows - 1)`. Assert `resizeSpy` was called exactly once. + - `it("ignores a host config font load that resolves after its renderer was disposed")`: deliver the config, then `await view.setVisible(false)` and wait for it to settle (the hide disposes the terminal; assert `terminal.dispose` was called). Then resolve the font promise inside `act`, and assert `clearTextureAtlas` and `refresh` were not called on that disposed terminal. +2. [ ] Run the first Done-when command. Expected: FAIL in the first test, `clearTextureAtlas` called 0 times. The second test passes. +3. [ ] Add `rendererEpoch` to the font effect's dependency list and return early when `rendererEpoch !== rendererEpochRef.current`. Keep the existing `cancelled`, generation and identity checks in the `.then` callback. +4. [ ] Run both Done-when commands. Expected: PASS. +5. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand (remove the dependency and the guard) and rerun the first command. Expected: FAIL. Restore it and rerun. Expected: PASS. +6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.3: Assert the repaint on a same-size return - Satisfies: R3. When a mount returns from no area to the same positive size, the terminal repaints. Test only. - Depends on: 1.2 - Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes the existing `it.each([false, true])("never sizes the PTY from a mount with no area and refits when it returns (ownership %s)")`. Produces nothing other tasks use. +- Interfaces: consumes the existing `it.each([false, true])("never sizes the PTY from a mount with no area and refits when it returns (ownership %s)")` and the trailing-refresh capture of "performs one trailing renderer refresh after element resize settles" (a `vi.spyOn(window, "setTimeout")` that keeps the 50 ms handler). Produces nothing other tasks use. - Skills: tdd (proving an added assertion can fail) -- Read first: that test, and the 50 ms trailing refresh in `scheduleResize` +- Read first: both tests. Also the `terminal.refresh` callers in `ThreadTerminalPanel.tsx`, which are: + - `repaintTerminalTheme` (no theme change in this test); + - the font effect (no font change); + - the WebGL canvas correction (WebGL is off in this test); + - the 50 ms timer in `scheduleResize`, the only one this test reaches. - Tier: cheap -- TDD: exempt (the behavior exists; the task adds an assertion and proves it by a temporary break) -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "never sizes the PTY from a mount with no area"` exits 0 for both cases. +- TDD: exempt (the behavior exists; the task adds an assertion and proves it with a temporary edit of the only code path that can satisfy it) +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "never sizes the PTY from a mount with no area"` exits 0 for both cases. Steps -1. [ ] In that test, call `terminal.refresh.mockClear()` before the mount returns to its old width. After `resizeContainer()` and `flushFrame()`, advance the 50 ms timer and assert `expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1)`, while `resizeSpy` stays uncalled. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "never sizes the PTY from a mount with no area"`. Expected: PASS. -3. [ ] Prove the test can fail: comment out `terminal.refresh(0, terminal.rows - 1)` in the `refreshTimer` callback of `scheduleResize`, then run the command. Expected: FAIL on the new assertion. Restore it and run again. Expected: PASS. -4. [ ] Run `vp check src/components/ThreadTerminalPanel.interactions.test.tsx`. Expected: no errors. +1. [ ] In that test, before the mount returns to its old width (`mount.style.width = ""`), call `terminal.refresh.mockClear()` and install the 50 ms capture spy from "performs one trailing renderer refresh after element resize settles". After `resizeContainer()` and `flushFrame()`, run the captured handler inside `act`. Assert `expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1)`, while `resizeSpy` stays uncalled. Restore the spy afterwards. Expected: the change is in place, and the next command step checks it. +2. [ ] Run the Done-when command. Expected: PASS. +3. [ ] Prove the test can fail: this task adds no product code, so a revert proves nothing. Instead delete `terminal.refresh(0, terminal.rows - 1)` from the 50 ms timer in `scheduleResize` by hand (the only caller this test reaches, per Read first) and rerun. Expected: FAIL on the new assertion. Restore it and rerun. Expected: PASS. +4. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.interactions.test.tsx`. Expected: no errors. ### Task 1.4: Development record of size and repaint decisions @@ -99,112 +138,178 @@ Steps - `export const TERMINAL_SIZE_DIAGNOSTICS_CAPACITY = 200;` - `export type TerminalSizeDiagnosticTrigger = TerminalSizeTrigger | "font" | "pixel-ratio" | "resize-answer" | "repaint";` - `export interface TerminalSizeDiagnosticRecord { readonly at: number; readonly trigger: TerminalSizeDiagnosticTrigger; readonly rendererGeneration: number; readonly mountWidth: number; readonly mountHeight: number; readonly fitted: TerminalDimensions | null; readonly applied: TerminalDimensions | null; readonly role: "owner" | "mirror" | "legacy"; readonly devicePixelRatio: number; readonly fontStatus: "loading" | "loaded" | "unavailable"; readonly renderer: "webgl" | "standard"; readonly request: { readonly cols: number; readonly rows: number; readonly outcome: TerminalResizeOutcome | "pending" | "failed" } | null; readonly repaint: "none" | "scheduled" | "done"; }` - - `export function setTerminalSizeDiagnosticsEnabled(enabled: boolean): void` (ignored unless `import.meta.env.DEV`; when it disables, it also clears the ring) + - `export function setTerminalSizeDiagnosticsEnabled(enabled: boolean): void` (ignored unless `import.meta.env.DEV`; disabling also clears the ring) - `export function terminalSizeDiagnosticsEnabled(): boolean` - `export function recordTerminalSizeDiagnostic(record: Omit): void` (a no-op when disabled; stamps `at` with `performance.now()`) - `export function readTerminalSizeDiagnostics(): readonly TerminalSizeDiagnosticRecord[]` (oldest first, a copy) - `export function clearTerminalSizeDiagnostics(): void` Records hold no thread, terminal or session ids and no strings from the terminal. In development, the panel module also sets `window.__bibcodeTerminalSizeDiagnostics = { enable: setTerminalSizeDiagnosticsEnabled, read: readTerminalSizeDiagnostics }`, which a developer calls from the console. + + The panel records at every decision point: + - every exit of `handleSizeTrigger` after its `disposed`, generation and center-resize guards: the deferred-claim branch (`request: null`), the legacy `!sizeOwnership` branch (after `requestTerminalResize`), and the final `updateSizeNotice(fitted)`; + - the completions of `finishClaim` and `requestTerminalResize`, with trigger `"resize-answer"`; + - the 50 ms timer of `scheduleResize` (`repaint: "scheduled"` when set, `"done"` when run); + - the font effect's repaint (`"font"`); + - `handlePixelRatioChange` (`"pixel-ratio"`). - Skills: tdd (ring and panel recording are red first); vercel-react-best-practices (recording must not add renders or effect dependencies) -- Read first: `ThreadTerminalPanel.tsx` (`handleSizeTrigger`, `completeClaim`, `finishClaim`, `requestTerminalResize`, `scheduleResize`, the font effect); `AGENTS.md` section Sensitive Data +- Read first: `ThreadTerminalPanel.tsx`: `handleSizeTrigger` and its 12 call sites (`rg -n "handleSizeTrigger\(" ThreadTerminalPanel.tsx`), `completeClaim`, `finishClaim`, `requestTerminalResize`, `scheduleResize`, the font effect and `handlePixelRatioChange`. Also `AGENTS.md`, section Sensitive Data. - Tier: standard - TDD: red-green -- Done when: `vp test run --project unit src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.interactions.test.tsx -t "size diagnostics"` exits 0. +- Done when: from `apps/web`, `vp test run --project unit src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.interactions.test.tsx -t "size diagnostics"` exits 0 and runs five tests. Steps -1. [ ] In `terminalSizeDiagnostics.test.ts` write three tests: - - `it("keeps the last 200 records, oldest first")`: record 205 entries and assert the length is 200 and the first `rendererGeneration` is 5. - - `it("records nothing while disabled")`. - - `it("clears the ring when disabled")`. -2. [ ] In `ThreadTerminalPanel.interactions.test.tsx` write `it("records size diagnostics without terminal content")`. Enable diagnostics. Write the output `"secret-marker-value"` through the runtime, resize the container and flush the frame and the 50 ms timer. Assert that a record with trigger `"layout"` has every field set and `repaint` reaches `"done"`. Assert that `JSON.stringify(readTerminalSizeDiagnostics())` does not contain `"secret-marker-value"`. Write `it("records no size diagnostics while disabled")`. -3. [ ] Run `vp test run --project unit src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.interactions.test.tsx -t "size diagnostics"`. Expected: FAIL, the module is not found. -4. [ ] Create the module with the exports above, as a fixed-size array with a write index. In the panel, call `recordTerminalSizeDiagnostic` in five places: - - at the end of `handleSizeTrigger`, with its trigger, fitted, applied and the role from `sizeOwnership` and the mirror state; - - in `finishClaim` and in the `requestTerminalResize` completion, with trigger `"resize-answer"` and the outcome; - - when `scheduleResize` schedules the trailing refresh (`"scheduled"`) and when it runs (`"done"`); - - in the font effect; - - in the pixel-ratio handler. - - Read `mount.clientWidth`, `mount.clientHeight`, `globalThis.devicePixelRatio`, `document.fonts?.status ?? "unavailable"`, and the renderer kind from `webglLifecycleRef.current !== null`. Wrap each call site in `if (terminalSizeDiagnosticsEnabled())` so nothing is measured when diagnostics are off. -5. [ ] Run the step 3 command. Expected: PASS. -6. [ ] Prove the test can fail: make `recordTerminalSizeDiagnostic` ignore the enabled flag, then run the command. Expected: FAIL in "records nothing while disabled". Restore it and run again. Expected: PASS. -7. [ ] Run `vp check src/components/terminalSizeDiagnostics.ts src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] In `terminalSizeDiagnostics.test.ts`, write three tests on the real module, with `beforeEach` calling `setTerminalSizeDiagnosticsEnabled(true)` and `clearTerminalSizeDiagnostics()`: + - `it("size diagnostics keep the last 200 records, oldest first")`: record 205 entries with `rendererGeneration` 0 to 204, and assert the length is 200 and the first `rendererGeneration` is 5; + - `it("size diagnostics record nothing while disabled")`; + - `it("size diagnostics clear the ring when disabled")`. +2. [ ] In `ThreadTerminalPanel.interactions.test.tsx`, write two new tests on the real viewport. Expected: the change is in place, and the next command step checks it. + - `it("records size diagnostics without terminal content")`: enable diagnostics, mount, run `view.flushFrame()` and `view.flushMountFit()`. Call `view.emitOutput("secret-marker-value")`. Install the 50 ms capture spy from "performs one trailing renderer refresh after element resize settles", call `view.resizeContainer()` and `view.flushFrame()`, and run the captured handler inside `act`. Assert: + - a record with trigger `"layout"` has every field set; + - a record has `repaint: "done"`; + - `JSON.stringify(readTerminalSizeDiagnostics())` does not contain `"secret-marker-value"`. + - `it("records no size diagnostics while disabled")`. + + Each test disables and clears the ring in `afterEach`. +3. [ ] Run the Done-when command. Expected: FAIL, `./terminalSizeDiagnostics` cannot be resolved. +4. [ ] Create the module with the exports above, as a fixed-size array with a write index. Add the panel call sites listed in Interfaces. Read the values from: + - `mount.clientWidth` and `mount.clientHeight`; + - `globalThis.devicePixelRatio`; + - `document.fonts?.status ?? "unavailable"`; + - the renderer kind, `webglLifecycleRef.current !== null`. + + Wrap each call site in `if (terminalSizeDiagnosticsEnabled())`, so nothing is measured when diagnostics are off. +5. [ ] Run the Done-when command. Expected: PASS, 5 tests. +6. [ ] Prove the test can fail, in two parts: + 1. Revert this task's `ThreadTerminalPanel.tsx` edits by hand, keeping the new module, and rerun. Expected: FAIL in "records size diagnostics without terminal content" (no records). Restore them and rerun. Expected: PASS. + 2. The ring tests have no panel code to revert. Delete the `if (!enabled) return;` line in `recordTerminalSizeDiagnostic` and rerun. Expected: FAIL in "size diagnostics record nothing while disabled", because the ring then holds the recorded entry. Restore it and rerun. Expected: PASS. +7. [ ] From `apps/web`, run `vp check src/components/terminalSizeDiagnostics.ts src/components/terminalSizeDiagnostics.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.5: Say that the server kept the size when Fit sends nothing - Satisfies: R17 (D13). When "Fit to this window" is pressed and no size request can be sent, the size notice says that the server kept this size, in a status region. No request is sent and focus stays on the button. - Depends on: 1.4 - Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes `renderer.fit()` (it calls `handleSizeTrigger("fit")` and compares `claimInFlight` before and after) and the size notice `div` with `role="status"`. Produces a `fitKept` boolean state in `TerminalViewport`. It is set to `true` by `fit()` when no new claim was sent. It is reset to `false` by `applySize` when the applied size or owner changes, and when a claim is sent. +- Interfaces: consumes `renderer.fit()`. Its only caller is the Fit button's `onClick={() => rendererRef.current?.fit()}`. It calls `handleSizeTrigger("fit")` and compares `claimInFlight` before and after. Also consumes the size notice `div` with `role="status"`, shown while `shouldRender && sizeOwnership && mirroringSize !== null`. + + Produces a `fitKept` boolean state in `TerminalViewport`, with these writers: + - `fit()` sets it to `claimInFlight === pending`; + - `applySize` sets it to `false` when `!terminalSizesEqual(appliedSize, size)`; its four callers are the `resized` and `attached` branches of `render`, the reset branch and `reconcileLegacyClaim`; + - `handleSizeTrigger` sets it to `false` when it sends a claim (`claimInFlight = request`); + - the creation effect's cleanup sets it to `false`, next to `updateMirroringSize(null)`. - Skills: tdd (red-green for the new text); vercel-react-best-practices (one new state, set only from event paths); better-writing (UI.md review of the new sentence) -- Read first: `UI.md` (disabled and refused actions explain themselves); the tests "keeps focus on Fit to this window when no claim is sent" and "leaves the terminal focused after Fit to this window takes the size" +- Read first: `UI.md` (disabled and refused actions explain themselves); in the two-machine describe ("TerminalViewport shared by windows on two machines (#348)"), the helpers `serverHostOwnsTallerGrid`, `fitButton`, `view`, and the tests "keeps focus on Fit to this window when no claim is sent" and "leaves the terminal focused after Fit to this window takes the size" - Tier: standard - TDD: red-green -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "Fit to this window"` exits 0, including the unchanged test "keeps focus on Fit to this window when no claim is sent". +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "server kept this size"` exits 0 and runs two tests, and the same command with `-t "Fit to this window"` exits 0 (including the unchanged "keeps focus on Fit to this window when no claim is sent"). Steps -1. [ ] Write `it("says the server kept this size when Fit to this window sends nothing")`, built like "keeps focus on Fit to this window when no claim is sent". After the second click, assert the notice's `role="status"` element contains the text `The server kept this size.`, the resize call count is unchanged and `document.activeElement` is the Fit button. Add `it("drops the kept-size sentence when the applied size changes")`: a later `resized` event with another size, then assert the sentence is gone. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "server kept this size"`. Expected: FAIL, the text is not found. -3. [ ] Add `const [fitKept, setFitKept] = useState(false)`. In `fit()`, call `setFitKept(claimInFlight === pending)`. In `applySize`, call `setFitKept(false)` when `!terminalSizesEqual(appliedSize, size)`. In the notice, render `{fitKept ? " The server kept this size." : ""}` inside the existing `` of the `role="status"` div, so no second live region is added. -4. [ ] Run the Done-when command. Expected: PASS, including "keeps focus on Fit to this window when no claim is sent". -5. [ ] Prove the test can fail: render the sentence only when `fitKept && false`, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. -6. [ ] Review the sentence against `UI.md`: it says what happened, in the user's words, and the Fit button stays available. Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] In the two-machine describe, write two new tests with its real helpers. + - `it("says the server kept this size when Fit to this window sends nothing")`, built like "keeps focus on Fit to this window when no claim is sent" (the same `claimNotRegistered` mock and two clicks). After the second click, assert: + - the notice's `role="status"` element contains `The server kept this size.`; + - the resize call count is unchanged; + - `document.activeElement` is the Fit button. + - `it("drops the server kept this size sentence when the applied size changes")`: after the same two clicks, deliver a `resized` event with another size through the harness's `server`, await `server.deliver()`, and assert the sentence is gone. +2. [ ] Run the first Done-when command. Expected: FAIL in both, the text is not found. +3. [ ] Add `const [fitKept, setFitKept] = useState(false)` and the writers in Interfaces. In the notice, render `{fitKept ? " The server kept this size." : ""}` inside the existing `` of the `role="status"` div, so no second live region is added. +4. [ ] Run both Done-when commands. Expected: PASS. +5. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand and rerun the first command. Expected: FAIL. Restore it and rerun. Expected: PASS. +6. [ ] Review the sentence against `UI.md`: it says what happened, in the user's words, and the Fit button stays available. From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.6: A dedicated hand-over capability -- Satisfies: R41 (D12). The client applies the typed-last size hand-over rule only when the server advertises `terminalSizeHandOver`. A server without it gets the old behavior. +- Satisfies: R41 (D12). The client applies the typed-last size hand-over rule only when the server advertises `terminalSizeHandOver`. A server without it gets the old behavior, even when its sizes carry `replyClaim`. - Depends on: 1.5 -- Files: modify `packages/contracts/src/environment.ts`, `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts`, `apps/web/src/components/ThreadTerminalPanel.tsx`; test `packages/contracts/src/environment.test.ts`, the capability tests in `lifecycle.rs` and `production/control.rs`, and `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; regenerate `packages/contracts/fixtures/**` -- Interfaces: consumes `ExecutionEnvironmentCapabilities` and the panel's `userInputClaim` (`sizeOwnership && capabilities.terminalUserInputClaim === true`), which keeps driving `inputOrigin`. Produces the field `terminalSizeHandOver: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false)))` and a panel constant `sizeHandOver = sizeOwnership && serverConfig?.environment.capabilities?.terminalSizeHandOver === true`. That constant replaces `userInputClaim` only in `unownedUntilUsed = … && signal.size.sizeClaim === null`, and it is added to the renderer effect's dependency list. +- Files: modify `packages/contracts/src/environment.ts`, `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts`, `apps/web/src/components/ThreadTerminalPanel.tsx`; test `packages/contracts/src/environment.test.ts`, `lifecycle.rs` (`connect_descriptor_advertises_remote_update_support`), `production/control.rs` (`environment_descriptor_advertises_remote_update_control_and_support`) and `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; regenerate `packages/contracts/fixtures/**` +- Interfaces: consumes `ExecutionEnvironmentCapabilities` and the panel's `userInputClaim` (`sizeOwnership && serverConfig?.environment.capabilities?.terminalUserInputClaim === true`). `userInputClaim` keeps driving `inputOrigin` and stays in the renderer effect's dependencies. + + Produces: + - the field `terminalSizeHandOver: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false)))`; + - the panel constant `sizeHandOver = sizeOwnership && serverConfig?.environment.capabilities?.terminalSizeHandOver === true`, added to the renderer effect's dependency list. + + Every entry into the hand-over state (`unownedUntilUsed = true`) is gated by `sizeHandOver`. There are exactly two writers that set it `true`: + 1. `applySize`: `else if (isUnownedUntilUsed(size)) unownedUntilUsed = true;` becomes `else if (sizeHandOver && isUnownedUntilUsed(size))`. `isUnownedUntilUsed` in `terminalSizePolicy.ts` reads `replyClaim` and no capability. `applySize` is reached from the `resized` branch, the `attached` branch, the reset branch (snapshot and restart) and `reconcileLegacyClaim`. + 2. The `resized` branch of `render`: `unownedUntilUsed = userInputClaim && signal.size.sizeClaim === null` becomes `sizeHandOver && …`. + + The writers that set it `false` (in `applySize` when `size.sizeClaim != null`, and in the reset branch) are unchanged. `answersTerminalQueries` also reads `replyClaim`, but it decides only which window answers queries, a rule the server states by naming the replier; it is not the hand-over state and stays ungated. - Skills: tdd (red-green for decoding, advertising and the panel rule); vercel-react-best-practices (the dependency list change) -- Read first: `packages/contracts/src/environment.ts` (`terminalUserInputClaim`); `environment.test.ts` "defaults terminal size ownership off and preserves advertised support"; `lifecycle.rs` `connect_descriptor_advertises_remote_update_support`; `production/control.rs` `environment_descriptor` and `environment_descriptor_advertises_remote_update_control_and_support`; the interaction tests "waits for use of a size a handing-over server released, but claims a restart's (#348)" and "keeps an older server's rule: every window claims a released size and answers meanwhile" +- Read first: + - `rg -n "unownedUntilUsed|isUnownedUntilUsed|userInputClaim|replyClaim" apps/web/src/components/ThreadTerminalPanel.tsx apps/web/src/components/terminalSizePolicy.ts`, for every reader and writer; + - `packages/contracts/src/environment.ts` (`terminalUserInputClaim`) and the `environment.test.ts` test "defaults terminal size ownership off and preserves advertised support"; + - the two Rust tests named in Files, and `production/control.rs` `environment_descriptor`; + - `rg -n "terminalUserInputClaim: true" apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`: the hand-over tests "waits for use of a size a handing-over server released, but claims a restart's (#348)" and the two-machine describe's `beforeEach`, plus the input-origin tests near "marks focus reports as the terminal's own even right after a key (#348)", which stay without the new flag; + - the harness's `announceAttached` path in `packages/client-runtime/src/state/terminalTranscriptRuntime.ts` (read only), which emits `attached` to the renderer whose claim matches a stream source. - Tier: standard - TDD: red-green -- Done when: `vp test run packages/contracts/src/environment.test.ts` exits 0; `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exits 0 with 2 tests run; from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "released"` exits 0; `vp run check:contracts` exits 0 +- Done when: from the repository root, `vp test run packages/contracts/src/environment.test.ts` exits 0; from the repository root, `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exits 0 with 2 tests run; from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "hand-over capability"` exits 0 and runs every new case; the same command with `-t "handing-over server released"` and with `-t "shared by windows on two machines"` exits 0; from the repository root, `vp run check:contracts` exits 0. Steps 1. [ ] Write the failing tests: - - In `environment.test.ts`, `it("defaults the terminal size hand-over off and preserves advertised support")`. + - In `environment.test.ts`, `it("defaults the terminal size hand-over off and preserves advertised support")`, built like the size-ownership test. - In both Rust tests, add `assert_eq!(descriptor["capabilities"]["terminalSizeHandOver"], true)`. - - In the panel tests, set `terminalSizeHandOver: true` in "waits for use of a size a handing-over server released…". - - Add `it("keeps the older rule for a server with input claims but no hand-over capability")`. It uses `{ terminalSizeOwnership: true, terminalUserInputClaim: true }` and asserts a released size (`sizeClaim: null`) is claimed at once, as in "keeps an older server's rule…". -2. [ ] Run the four Done-when commands except `check:contracts`. Expected: each FAILS on the new assertion. The Rust one fails with `left: Null, right: true`. + - In the interaction test, add `terminalSizeHandOver?: boolean` to the `capabilities` type of `testState.serverConfig`. Add `terminalSizeHandOver: true` to the "waits for use of a size a handing-over server released…" setup and to the two-machine describe's `beforeEach`. Leave the input-origin tests as they are. + - Add `it.each([["absent", {}], ["false", { terminalSizeHandOver: false }]] as const)` with title `"keeps the claim rule without the hand-over capability (%s): reset, attached and resized"`. It uses the real viewport with `{ terminalSizeOwnership: true, terminalUserInputClaim: true, ...extra }` and `hasFocus` returning `false`, as the "handing-over server released" test does. It drives `applySize` through all three signals: + 1. Ingest a snapshot whose size is `{ cols: 151, rows: 50, sizeClaim: null, replyClaim: "other-window" }`, then assert a claim was sent (`resizeSpy` called) and "Fit to this window" is not shown. + 2. Clear the spy. Re-ingest the same snapshot event (same `sequence`) from the stream source `{ sizeClaim: claim }`, where `claim` is `testState.attachedSessionInputs.at(-1).terminal.sizeClaim`, so the runtime emits `attached`. Assert a claim was sent again. + 3. Clear the spy. Ingest `resized` with `{ cols: 151, rows: 50, sizeClaim: null, replyClaim: "other-window" }` and assert a claim was sent. + + Flush frames with `view.flushFrame()` after each signal, and call `elapseMountSettle()` before the first, so the settle wait is not what holds a claim. + - Add `it("applies the hand-over rule with the hand-over capability: reset, attached and resized")`, with the same steps and `terminalSizeHandOver: true`. After each signal, assert `resizeSpy` was not called and "Fit to this window" is shown. +2. [ ] Run the first four Done-when commands. Expected: each FAILS on a new assertion. The Rust one fails with `left: Null, right: true`. The "absent" and "false" panel cases fail at step 1, because today `isUnownedUntilUsed` enters the hand-over state from `replyClaim`. 3. [ ] Make the changes: - - Add the field to `environment.ts`. - - Add `"terminalSizeHandOver": true` to the `capabilities` literal in `lifecycle.rs`. - - Add `capabilities["terminalSizeHandOver"] = json!(true);` next to `terminalUserInputClaim` in `environment_descriptor`. - - Add `terminalSizeHandOver: false` to `makeTestExecutionEnvironmentCapabilities`. - - Add `terminalSizeHandOver?: boolean` to the interaction test's capabilities type. - - Add `sizeHandOver` to the panel as described. + - add the field to `environment.ts`; + - add `"terminalSizeHandOver": true` to the `capabilities` literal in `lifecycle.rs`; + - add `capabilities["terminalSizeHandOver"] = json!(true);` next to `terminalUserInputClaim` in `environment_descriptor`; + - add `terminalSizeHandOver: false` to `makeTestExecutionEnvironmentCapabilities`; + - add `sizeHandOver` to the panel and gate both writers as in Interfaces. 4. [ ] Rerun the step 2 commands. Expected: PASS. -5. [ ] Prove the test can fail: use `userInputClaim` again in the `unownedUntilUsed` line, then run the panel command. Expected: FAIL in the new "older rule" test. Restore it and run again. Expected: PASS. -6. [ ] Run `vp run check:contracts`. Expected: FAIL at `git diff --exit-code --stat -- packages/contracts/fixtures`, listing regenerated fixtures. Stage those fixture files (`git add packages/contracts/fixtures`), check that the diff only adds `terminalSizeHandOver`, and run it again. Expected: PASS. -7. [ ] Run `cargo fmt --all --check`, `cargo clippy -p bibcode-server --all-targets -- -D warnings`, `vp check` on the changed TypeScript files, and `vp run typecheck`. Expected: no errors. +5. [ ] Prove the test can fail: revert this task's `ThreadTerminalPanel.tsx` change by hand (restore `userInputClaim` in the `resized` writer and the ungated `isUnownedUntilUsed` line) and rerun the panel command with `-t "hand-over capability"`. Expected: FAIL in the "absent" and "false" cases. Restore it and rerun. Expected: PASS. +6. [ ] From the repository root, run `vp run check:contracts`. Expected: FAIL at `git diff --exit-code --stat -- packages/contracts/fixtures`, listing regenerated fixtures. Check that the regenerated diff only adds `terminalSizeHandOver`, stage those fixture files (`git add packages/contracts/fixtures`), and rerun. Expected: PASS. +7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` from the repository root. From `apps/web`, run `vp check` on the changed web files, then `vp run typecheck`. Expected: no errors. ### Task 1.7: Retry WebGL in a new terminal and say why it failed - Satisfies: R42. A new terminal in the same viewport tries WebGL again after an earlier context loss, and the fallback warning includes the cause. - Depends on: 1.6 - Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes `webglFailedRef`, `webglDiagnosticRecordedRef`, `rendererEpochRef` (incremented in the terminal creation effect) and `recordWebglDiagnosticOnce`. Produces `recordWebglDiagnosticOnce(cause: unknown): void`, which calls `console.warn("[terminal] WebGL renderer unavailable; using the standard renderer.", { cause })`. Context loss passes `cause: "context lost"`; a load or attach error passes the caught error. Both refs are set back to `false` where `rendererEpochRef.current += 1` runs. +- Interfaces: consumes `webglFailedRef`, `webglDiagnosticRecordedRef` and `recordWebglDiagnosticOnce`. Their writers and readers are: + - `webglFailedRef` is set `true` in `addon.onContextLoss` and in the WebGL effect's `catch`, and read in the WebGL effect's guard; + - `recordWebglDiagnosticOnce` is called from those same two places and listed in the effect's dependencies; + - `rendererEpochRef.current += 1` runs in the terminal creation effect. + + The existing tests require the warning to carry no raw error text (`expect(diagnosticSpy.mock.calls.flat().join(" ")).not.toContain("sensitive")`). So the cause is a stage, not a message (**agent's call**). Produces: + - `type WebglFailureStage = "context lost" | "import failed" | "addon creation failed" | "addon load failed"`; + - `recordWebglDiagnosticOnce(stage: WebglFailureStage, error?: unknown): void`, which calls `console.warn("[terminal] WebGL renderer unavailable; using the standard renderer.", { cause: stage, error: error instanceof Error ? error.name : error === undefined ? undefined : typeof error })`. + + The WebGL effect keeps a local `let stage: WebglFailureStage = "import failed"`. It becomes `"addon creation failed"` before `new WebglAddon()` and `"addon load failed"` before `terminal.loadAddon(addon)`, and stays so through the canvas observer and `onContextLoss` registration. Both refs are set to `false` next to `rendererEpochRef.current += 1`. - Skills: tdd (red-green for the retry and the cause); vercel-react-best-practices (callback signature change) -- Read first: the WebGL effect (`addon.onContextLoss`, the `catch` block); the tests "falls back once on context loss and does not retry in the same viewport lifetime", "does not steal focus when WebGL resolves, toggles, or loses context" and "allows a fresh viewport component to retry after a prior component failed" +- Read first: the WebGL effect (`loadTerminalWebglAddon`, `new WebglAddon()`, `terminal.loadAddon(addon)`, `addon.onContextLoss`, the `catch`). In the test file: + - the fakes `vi.mock("@xterm/addon-webgl")` (`triggerContextLoss`, `constructorShouldThrow`, `activateShouldThrow`, `listenerShouldThrow`), `xtermState.loadWebglShouldThrow` and `vi.mock("./terminalWebgl")` (`importGate`, `rejectWebglImport`); + - the tests "falls back once on context loss and does not retry in the same viewport lifetime", "falls back after a %s failure, diagnoses without raw errors, and does not retry", "falls back after an import rejection and keeps the diagnostic error-free" and "allows a fresh viewport component to retry after a prior component failed". - Tier: standard - TDD: red-green -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "WebGL"` exits 0, and so does the same command with `-t "context loss"`. +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "WebGL"` exits 0 (the two new tests), and so does the same command with `-t "falls back"` (the three updated tests). Steps -1. [ ] Write `it("tries WebGL again in a new terminal after a context loss in the same viewport")`. Trigger the context loss, then `setVisible(false)` and `setVisible(true)`, which creates a new xterm in the same viewport. Settle WebGL and assert `webglState.instances` has length 2. Write `it("includes the cause in the WebGL fallback warning")`, which rejects the addon import with `new Error("no context")` and asserts `console.warn` was called with the message and `{ cause: expect.objectContaining({ message: "no context" }) }`. Update "falls back once on context loss and does not retry in the same viewport lifetime" to expect the message with `{ cause: "context lost" }`. That test toggles the setting without a new terminal, so it still expects one instance. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "WebGL"`. Expected: FAIL, 1 instance instead of 2, and the warning has no cause. -3. [ ] Implement `recordWebglDiagnosticOnce(cause)`, pass the cause from both failure paths, and reset `webglFailedRef.current` and `webglDiagnosticRecordedRef.current` to `false` next to `rendererEpochRef.current += 1`. -4. [ ] Run both Done-when commands. Expected: PASS. -5. [ ] Prove the test can fail: remove the `webglFailedRef.current = false` reset, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. -6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] Write two new tests on the real viewport with the WebGL fakes. + - `it("tries WebGL again in a new terminal after a context loss in the same viewport")`: mount with `webglEnabled: true`, call `view.settleWebgl()`, trigger the context loss, `await view.setVisible(false)`, `await view.setVisible(true)` (a new fake terminal in the same viewport: assert `xtermState.terminals` grew by one), `await view.settleWebgl()`. Assert `webglState.instances` has length 2. + - `it("names the WebGL fallback cause without raw error text")`: mount with `deferWebglImport: true`, call `view.rejectWebglImport()`, and assert `console.warn` was called once with the message and `{ cause: "import failed", error: "Error" }`. Also assert that `JSON.stringify(diagnosticSpy.mock.calls)` does not contain `"sensitive"`. + + Update the three existing "falls back" tests to expect the message with: + - `{ cause: "context lost", error: undefined }` for context loss; + - for the `it.each` cases, `{ cause: "addon creation failed", error: "Error" }` for construction, and `{ cause: "addon load failed", error: "Error" }` for xterm load, activation and context-listener registration; + - nothing beyond `toHaveBeenCalledOnce()` for the import rejection test. + + In each of the three, add the `JSON.stringify(...)` check. They toggle the setting without a new terminal, so they still expect no retry. +2. [ ] Run both Done-when commands. Expected: FAIL. The first test sees 1 instance instead of 2. The cause tests see the message with no second argument. +3. [ ] Implement as in Interfaces. +4. [ ] Run both Done-when commands. Expected: PASS. Also run `-t "allows a fresh viewport component to retry"`. Expected: PASS. +5. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand and rerun the `-t "WebGL"` command. Expected: FAIL. Restore it and rerun. Expected: PASS. +6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.8: Decide whether a re-attach sends a transient size (R16) @@ -213,28 +318,41 @@ Steps - Otherwise the result is recorded and no code changes. - Depends on: 1.1, 1.4, 1.7 - Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; modify `apps/web/src/components/ThreadTerminalPanel.tsx` only in the defect branch -- Interfaces: consumes `readTerminalSizeDiagnostics()`, `setTerminalSizeDiagnosticsEnabled(true)`, `TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS` (100), `TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS` (500) and the mount-settle state in the renderer (`mountSettleRemaining`, `mountSizeChangedAt`, `mountUnsettledSince`). Produces, only in the defect branch, a renderer method `noteGeometryInputChanged(): void`. It sets `mountSizeChangedAt = performance.now()` and `mountUnsettledSince ??= mountSizeChangedAt`, and is called from the Task 1.1 `loadingdone` listener before `scheduleResize`. +- Interfaces: consumes `readTerminalSizeDiagnostics()`, `setTerminalSizeDiagnosticsEnabled(true)`, `clearTerminalSizeDiagnostics()`, `TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS` (100) and `TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS` (500). Also consumes the renderer's mount-settle state: + - `mountSettleRemaining` (reads `mount.clientWidth` and `mount.clientHeight`); + - `mountSizeChangedAt` and `mountUnsettledSince`; + - `deferredClaimTimer` and `deferredTrigger`, written only inside `handleSizeTrigger` and the creation effect's cleanup. + + Produces, only in the defect branch, a renderer method `noteGeometryInputChanged(): void`. It sets `mountSizeChangedAt = performance.now()` and `mountUnsettledSince ??= mountSizeChangedAt`, and is called from the Task 1.1 `loadingdone` listener before `scheduleResize`. - Skills: mstack-diagnose (decide by test; the test is the deliverable); tdd (red-green in the defect branch) -- Read first: `ThreadTerminalPanel.tsx` (`mountSettleRemaining`, `handleSizeTrigger`, the deferred claim timer); the test "mounts a correlated provider dock in the exact terminal viewport" (how the dock is mounted); "sends one claim at the final size after a mount passes through a few pixels" +- Read first: `ThreadTerminalPanel.tsx` (`mountSettleRemaining`, `handleSizeTrigger`, the deferred claim timer). In the describe "automatic claims wait for a settled mount (#348)": its `clientWidth`/`clientHeight` overrides (`mountSizes`), `resizeMount`, `settle`, `claimsOf` and the test "sends one claim at the final size after a mount passes through a few pixels". Also "mounts a correlated provider dock in the exact terminal viewport". - Tier: most capable - TDD: red-green -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"` exits 0, and the outcome (defect fixed, or no defect) is written in the phase report with the recorded request sequence. +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"` exits 0, and the outcome (defect fixed, or no defect) is written in the phase report with the recorded request sequence. Steps -1. [ ] Write `it("sends only the final size after a re-attach, a font-metrics change and an Activity dock change (re-attach transient)")` with ownership on and the window focused. Steps of the test: - - Enable diagnostics and attach with a snapshot sized `{ cols: 85, rows: 30 }`. - - Within 100 ms, change the proposed rows to 33 and dispatch `loadingdone` (font metrics). - - Within another 100 ms, open the Activity dock, so the mount shrinks and the proposal becomes 30 rows. - - Flush all timers. - - From `readTerminalSizeDiagnostics()`, collect `request` rows in order. Assert there is no `request.rows` sequence that goes to a value and then back, and that the last request equals the final fit. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. - - Expected, defect branch: FAIL, with requests such as `[33, 30]`. Go to step 3. - - Expected, no-defect branch: PASS. Make the test fail on purpose by setting `TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS` to 0, confirm it fails, and restore it. Then log "R16: no transient; requests …" in the phase report and stop. -3. [ ] Defect branch: add `noteGeometryInputChanged()` to the renderer as in Interfaces. Call it from the `loadingdone` listener before `scheduleResize`, so a font change restarts the 100 ms quiet period like a mount change does. The dock change already resizes the mount and is already covered. -4. [ ] Run the step 2 command. Expected: PASS. Run `-t "sends one claim at the final size"` and `-t "does not claim the sizing minimum at the maximum wait"`. Expected: PASS. -5. [ ] Prove the test can fail: remove the call from the listener, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. -6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] Inside the describe "automatic claims wait for a settled mount (#348)", write `it("sends only the final size after a re-attach, a font-metrics change and an Activity dock change (re-attach transient)")`. + + Harness decisions: + - It uses that describe's real timers and its `settle()` helper (a real 140 ms wait), as the other tests there do. No fake timers. + - The Activity dock is not mounted in this harness. Its only effect on sizing is a smaller mount box, so the dock change is reproduced by `resizeMount` with a smaller height. The real dock mounting is covered by "mounts a correlated provider dock in the exact terminal viewport". + - The font-metrics change is a new fit with more rows, delivered through `relayout` with the `document.fonts` stub from Task 1.1 dispatching `loadingdone` instead of the ResizeObserver. + + Steps of the test: + 1. Stub `document.fonts`, enable and clear diagnostics, and set `hasFocus` to return `true`. + 2. Open a window with `{ cols: 85, rows: 30 }` and `await settle()`. Record `before = claimsOf(owner).length`. + 3. Without awaiting `settle()`: set the proposal to `{ cols: 85, rows: 33 }` and dispatch `loadingdone` inside `act`, flushing frames. Then `resizeMount(owner, { width: 600, height: 400 }, { cols: 85, rows: 30 })` for the dock. + 4. `await settle()` twice. + 5. Assert `claimsOf(owner).slice(before)` contains no row sequence that goes to 33 and back to 30. Assert its last entry, if any, is `"85x30"`. Assert the `request` rows in `readTerminalSizeDiagnostics()` agree. + 6. Disable diagnostics in `finally`. +2. [ ] Run the Done-when command. Expected: PASS means no defect, and FAIL means the defect branch. + - Defect branch: FAIL with claims such as `["85x33", "85x30"]`. Go to step 3. + - No-defect branch: PASS. Prove the test can see a transient: temporarily make `mountSettleRemaining` return `0` (it is the only gate that holds an automatic claim, so the font step's 33 rows are then claimed at once, before the dock's 30) and rerun. Expected: FAIL. Restore it and rerun. Expected: PASS. Then log "R16: no transient; claims …" with the recorded sequence in the phase report and stop. +3. [ ] Defect branch: add `noteGeometryInputChanged()` as in Interfaces, and call it from the `loadingdone` listener before `scheduleResize`. A font change then restarts the 100 ms quiet period like a mount change does. The dock change already resizes the mount, so it is already covered. +4. [ ] Run the Done-when command. Expected: PASS. Run `-t "sends one claim at the final size"` and `-t "does not claim the sizing minimum at the maximum wait"`. Expected: PASS. +5. [ ] Defect branch only: revert this task's change to `ThreadTerminalPanel.tsx` by hand and rerun. Expected: FAIL. Restore it and rerun. Expected: PASS. +6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.9: Decide whether the grid is taller than its mount before the first snapshot (R43) @@ -243,59 +361,74 @@ Steps - Otherwise the result is recorded. - Depends on: 1.8 - Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; modify `apps/web/src/components/ThreadTerminalPanel.tsx` only in the defect branch -- Interfaces: consumes `terminalHostHasArea(host: Element): boolean` and `proposeTerminalDimensions(fit, host?): TerminalDimensions | null` from `terminalSizing.ts`, `fitTerminalSafely(fitAddon: FitAddon): boolean`, and `xtermState.proposedDimensions` in the test. Produces, only in the defect branch, a renderer flag `fittedBeforeFirstReset: boolean`. While it is `false`, the first `handleSizeTrigger("layout")` on a mount with area calls `fitTerminalSafely(fitAddon)` before any claim logic, in both ownership modes, and then sets the flag to `true`. The first `reset` render also sets it to `true`. +- Interfaces: consumes `terminalHostHasArea(host: Element): boolean` and `proposeTerminalDimensions(fit: Pick, host?: Element): TerminalDimensions | null` from `terminalSizing.ts`, and `fitTerminalSafely(fitAddon: FitAddon): boolean`. Its two callers are the creation effect right after `terminal.open(mount)`, and the legacy branch of `handleSizeTrigger`. With ownership, `handleSizeTrigger` never fits locally. + + Produces, only in the defect branch, a renderer flag `fittedBeforeFirstReset: boolean`, set to `true` by the reset branch of `render`. While it is `false`, the first `handleSizeTrigger("layout")` on a mount where `terminalHostHasArea(mount)` holds calls `fitTerminalSafely(fitAddon)` before any claim logic, in both ownership modes, and then sets the flag. - Skills: mstack-diagnose (decide by test; the test is the deliverable); tdd (red-green in the defect branch) -- Read first: the test "sends keystrokes through the input scheduler before the first snapshot" (the `publishRuntime: false` mount); the test "attaches without dimensions when the mount is created with no area"; the creation effect lines after `terminal.open(mount)` +- Read first: the creation effect from `new Terminal({` to the 30 ms `fitTimer`. In the test file: the fit fake (`xtermState.fitAppliesProposal`), and the tests "sends keystrokes through the input scheduler before the first snapshot" (the `publishRuntime: false` mount) and "attaches without dimensions when the mount is created with no area". - Tier: most capable - TDD: red-green -- Done when: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "before the first snapshot"` exits 0, and the outcome is written in the phase report with the recorded rows and mount height. +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "keeps the grid within its mount"` exits 0 for both cases, and the outcome is written in the phase report with the recorded rows and mount height. Steps -1. [ ] Next to "sends keystrokes through the input scheduler before the first snapshot", write `it.each([false, true])("keeps the grid within its mount before the first snapshot after a reload (ownership %s)")`. Steps of the test: - - Mount with `publishRuntime: false`, a mount height that fits 20 rows (`xtermState.proposedDimensions = { cols: 80, rows: 20 }`), and xterm's default 24 rows. - - Flush the 30 ms fit timer and the first frame. - - Read `terminal.rows`, `mount.clientHeight` and `proposeTerminalDimensions` before any snapshot is ingested. - - Assert `terminal.rows <= 20`. -2. [ ] Run `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "before the first snapshot"`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. - - Expected, defect branch: FAIL with `24 > 20` in one or both ownership cases. Go to step 3. - - Expected, no-defect branch: PASS. Make it fail on purpose by skipping `fitTerminalSafely` after `terminal.open(mount)`, confirm it fails, and restore it. Then log "R43: no tall grid; rows … mount … px" in the phase report and stop. -3. [ ] Defect branch: add `fittedBeforeFirstReset` as in Interfaces, and set it to `true` in the reset branch of `render`. -4. [ ] Run the step 2 command. Expected: PASS. Run `-t "attaches without dimensions when the mount is created with no area"` and `-t "issues one resize for the initial geometry"`. Expected: PASS. -5. [ ] Prove the test can fail: never set the flag's fit branch, then run the step 2 command. Expected: FAIL. Restore it and run again. Expected: PASS. -6. [ ] Run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] Next to "sends keystrokes through the input scheduler before the first snapshot", write `it.each([false, true])("keeps the grid within its mount before the first snapshot after a reload (ownership %s)")` on the real viewport. Steps of the test: Expected: the change is in place, and the next command step checks it. + 1. Set `xtermState.fitAppliesProposal = true`, so the fit fake really resizes as the real addon does. Set `xtermState.proposedDimensions = { cols: 80, rows: 20 }`, set the ownership capability per case, and mount with `publishRuntime: false`. The fake starts at 24 rows. + 2. Run `view.flushFrame()` and `view.flushMountFit()` (the 30 ms timer). Time is otherwise not advanced. + 3. Before any snapshot is ingested, read `terminal.rows`, `mount.clientHeight` and `proposeTerminalDimensions(fitAddon, mount)`. + 4. Assert `terminal.rows <= 20`. +2. [ ] Run the Done-when command. Expected: PASS means no defect, and FAIL means the defect branch. + - Defect branch: FAIL with `24 > 20` in one or both cases. Go to step 3. + - No-defect branch: PASS. Prove the test can see a tall grid: temporarily delete the `fitTerminalSafely(fitAddon)` call after `terminal.open(mount)` and rerun. Expected: FAIL in the ownership case (it has no other fit before a snapshot, per Interfaces). The legacy case may still pass through its 30 ms layout fit. Restore it and rerun. Expected: PASS. Then log "R43: no tall grid; rows … mount … px" in the phase report and stop. +3. [ ] Defect branch: add `fittedBeforeFirstReset` as in Interfaces. +4. [ ] Run the Done-when command. Expected: PASS. Run `-t "attaches without dimensions when the mount is created with no area"` and `-t "issues one resize for the initial geometry"`. Expected: PASS. +5. [ ] Defect branch only: revert this task's change to `ThreadTerminalPanel.tsx` by hand and rerun. Expected: FAIL. Restore it and rerun. Expected: PASS. +6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.10: Count columns by Unicode 11 in every terminal - Satisfies: R5 (D4). Every terminal in the client counts columns by Unicode 11, the `@xterm/addon-unicode11` provider. Given a real xterm built with the panel's options, when U+1F600 is written, then the cursor advances 2 columns; before the change it advances 1. - Depends on: 1.9 - Files: modify `apps/web/package.json` (add `"@xterm/addon-unicode11": "^0.9.0"` to `dependencies`, beside `@xterm/addon-webgl`); `pnpm-lock.yaml` is updated only by the executor's `vp install`; create `apps/web/src/components/terminalUnicode.ts`; test `apps/web/src/components/terminalUnicode.xterm.test.ts`; modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes `Unicode11Addon` from `@xterm/addon-unicode11`, and `Terminal` and `ITerminalOptions` from `@xterm/xterm`. Produces these exports of `terminalUnicode.ts`: +- Interfaces: consumes `Unicode11Addon` from `@xterm/addon-unicode11`, and `Terminal` and `ITerminalOptions` from `@xterm/xterm`. `rg -n "new Terminal\(" apps/web/src --glob '!*.test.*'` finds exactly one production constructor, in the panel's creation effect. Produces these exports of `terminalUnicode.ts`: - `export const TERMINAL_UNICODE_VERSION = "11";` - `export const TERMINAL_BASE_OPTIONS = { cursorBlink: true, macOptionClickForcesSelection: true, lineHeight: 1, fontSize: 12, scrollback: 5_000, allowProposedApi: true } as const satisfies ITerminalOptions;` - `export function activateTerminalUnicode(terminal: Pick): void`, which loads `new Unicode11Addon()` and sets `terminal.unicode.activeVersion = TERMINAL_UNICODE_VERSION`. The panel's `new Terminal({...})` spreads `TERMINAL_BASE_OPTIONS` in place of its five literal options, keeps `fontFamily`, `theme` and `linkHandler`, and calls `activateTerminalUnicode(terminal)` right after `terminal.loadAddon(fitAddon)` and before `terminal.open(mount)`. - Why `allowProposedApi: true`: in xterm 6.0.0, `node_modules/@xterm/xterm/src/browser/public/Terminal.ts` calls `_checkProposedApi()` in the `unicode` getter. That throws "You must set the allowProposedApi option to true to use proposed API" unless the option is set, and the addon's `activate` registers its provider through `terminal.unicode`. The same option only unlocks four other public members, and changes no behavior by itself: the `markers` getter, `registerCharacterJoiner`, `deregisterCharacterJoiner` and `registerDecoration`. The panel calls none of them today. `OptionsService` defaults it to `false`, and nothing else in xterm's sources reads it. + Why `allowProposedApi: true`: in xterm 6.0.0, `node_modules/@xterm/xterm/src/browser/public/Terminal.ts` calls `_checkProposedApi()` in the `unicode` getter. That throws "You must set the allowProposedApi option to true to use proposed API" unless the option is set, and the addon's `activate` registers its provider through `terminal.unicode`. The same option unlocks only four other public members, and changes no behavior by itself: the `markers` getter, `registerCharacterJoiner`, `deregisterCharacterJoiner` and `registerDecoration`. The panel calls none of them. `OptionsService` defaults it to `false`, and nothing else in xterm's sources reads it. - Skills: tdd (red-green with a real xterm); vercel-react-best-practices (options moved out of the render path, no new effect) -- Read first: `apps/web/src/components/terminalFollowOutput.xterm.test.ts` (it already builds a real `Terminal` with `allowProposedApi: true` under happy-dom); the `vi.mock("@xterm/xterm", …)` fake and its `loadAddon` in `ThreadTerminalPanel.interactions.test.tsx`; xterm's `src/browser/public/Terminal.ts` (`_checkProposedApi`) +- Read first: + - `apps/web/src/components/terminalFollowOutput.xterm.test.ts`, which already builds a real `Terminal` with `allowProposedApi: true` under `// @vitest-environment happy-dom` and mocks nothing; + - in `ThreadTerminalPanel.interactions.test.tsx`, the `vi.mock("@xterm/xterm", …)` fake. Its `loadAddon` records the addon and calls `addon.activate?.(this)`, and it has no `unicode` member, so the harness must change as in step 5; + - `ThreadTerminalPanel.test.tsx`, which mocks `@xterm/xterm` with a static `MockTerminal` but only server-renders. Effects never run there, so no terminal is built and it needs no change; it is in the Checkpoint to confirm; + - xterm's `src/browser/public/Terminal.ts` (`_checkProposedApi`). - Tier: standard - TDD: red-green -- Done when: `vp test run --project unit src/components/terminalUnicode.xterm.test.ts` exits 0, and so do the same command on `src/components/ThreadTerminalPanel.interactions.test.tsx` with `-t "Unicode 11"`, `-t "WebGL"` and `-t "font"`. +- Done when: from `apps/web`, `vp test run --project unit src/components/terminalUnicode.xterm.test.ts` exits 0; `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "Unicode 11"` exits 0 and runs two cases; the same command with `-t "WebGL"` and with `-t "font"` exits 0; and `vp test run --project unit src/components/ThreadTerminalPanel.test.tsx` exits 0. Steps 1. [ ] Executor step, not the task agent's: add the dependency line to `apps/web/package.json` and run `vp install` from the repository root. Expected: `pnpm-lock.yaml` gains `@xterm/addon-unicode11@0.9.0` and nothing else changes in it. The lockfile is reserved for the executor; a task agent never edits it. -2. [ ] Write `terminalUnicode.xterm.test.ts` (with `// @vitest-environment happy-dom`). Test `it("advances two columns for U+1F600 with the panel's options and one without Unicode 11")`: - - Build `new Terminal({ ...TERMINAL_BASE_OPTIONS, cols: 20, rows: 5 })` and call `activateTerminalUnicode`. - - Write `"\u{1F600}"` and wait for the write callback. Assert `buffer.active.cursorX` is 2. - - Build a second terminal with the same options and no activation, and assert `cursorX` is 1 (the Unicode 6 baseline). - - Assert `terminal.unicode.activeVersion` is `"11"`. -3. [ ] Run `vp test run --project unit src/components/terminalUnicode.xterm.test.ts`. Expected: FAIL, `terminalUnicode` cannot be resolved. +2. [ ] Write `terminalUnicode.xterm.test.ts` with `// @vitest-environment happy-dom` and no mocks: the real `@xterm/xterm` and the real addon. Test `it("advances two columns for U+1F600 with the panel's options and one without Unicode 11")`: + 1. Build `new Terminal({ ...TERMINAL_BASE_OPTIONS, cols: 20, rows: 5 })` and call `activateTerminalUnicode`. + 2. Write `"\u{1F600}"` with `await new Promise((resolve) => terminal.write(text, resolve))`. Assert `terminal.buffer.active.cursorX` is 2 and `terminal.unicode.activeVersion` is `"11"`. + 3. Build a second terminal with the same options and no activation, write the same way, and assert `cursorX` is 1 (the Unicode 6 baseline). + 4. Dispose both. +3. [ ] Run `vp test run --project unit src/components/terminalUnicode.xterm.test.ts`. Expected: FAIL, `./terminalUnicode` cannot be resolved. 4. [ ] Create `terminalUnicode.ts` with the exports above. Run the step 3 command. Expected: PASS. -5. [ ] In the interaction test, add `vi.mock("@xterm/addon-unicode11", …)`, returning a recorded `Unicode11Addon` class. Give the fake terminal `unicode = { activeVersion: "6" }`. Write `it("activates Unicode 11 on every terminal, with WebGL on or off")` as an `it.each([false, true])` over `webglEnabled`: assert `loadAddon` received the Unicode 11 instance, `unicode.activeVersion` is `"11"`, and with WebGL on `loadAddon` also received `view.webglAddon`. Run `-t "Unicode 11"`. Expected: FAIL, `activeVersion` is `"6"`. +5. [ ] Change the interaction harness: + - add `vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class Unicode11Addon { readonly kind = "unicode11" as const; activate() {} dispose() {} } }))`; + - give the fake `Terminal` a field `unicode = { activeVersion: "6" }`. + + Write the new test `it.each([false, true])("activates Unicode 11 on every terminal (WebGL %s)")` on the real viewport. Assert: + - `loadAddon` received an addon with `kind: "unicode11"`; + - `unicode.activeVersion` is `"11"`; + - with WebGL on, after `view.settleWebgl()`, `loadAddon` also received `view.webglAddon`. + + Run `-t "Unicode 11"`. Expected: FAIL, `activeVersion` is `"6"`. 6. [ ] In `ThreadTerminalPanel.tsx`, spread `TERMINAL_BASE_OPTIONS` and call `activateTerminalUnicode(terminal)` as in Interfaces. Run `-t "Unicode 11"`. Expected: PASS. -7. [ ] Check that the renderer and font paths are unchanged: run the interaction test with `-t "WebGL"` and with `-t "font"`. Expected: PASS. That covers the context-loss, fallback and DPR=2 WebGL correction tests and the font-change tests for both renderers. -8. [ ] Prove the test can fail: remove the `activeVersion` assignment in `activateTerminalUnicode`, then run the step 3 command. Expected: FAIL with `cursorX` 1. Restore it and run again. Expected: PASS. -9. [ ] Run `vp check src/components/terminalUnicode.ts src/components/terminalUnicode.xterm.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx` and `vp run typecheck`. Expected: no errors. +7. [ ] Check that the renderer and font paths are unchanged: run the interaction test with `-t "WebGL"` and with `-t "font"`, and run `src/components/ThreadTerminalPanel.test.tsx`. Expected: PASS. That covers the context-loss, fallback and DPR=2 WebGL correction tests and the font-change tests for both renderers. +8. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand (the spread and the call) and rerun `-t "Unicode 11"`. Expected: FAIL. Restore it and rerun. Expected: PASS. Then revert the `activeVersion` line in `activateTerminalUnicode` by hand and rerun the step 3 command. Expected: FAIL with `cursorX` 1. Restore it and rerun. Expected: PASS. +9. [ ] From `apps/web`, run `vp check src/components/terminalUnicode.ts src/components/terminalUnicode.xterm.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md index b2e1b671b..034726275 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md @@ -72,99 +72,113 @@ Steps 3. [ ] Run `cargo test -p bibcode-server --lib sign_in::tests::the_missing_username_refusal_needs_a_fatal_line -j 2`. Expected: FAIL on assertion (a) or (b) (the old detector accepts them). 4. [ ] Implement in `sign_in.rs`: `detail.split(['\n', '\r']).any(|line| { let line = line.trim_start().to_ascii_lowercase(); line.starts_with("fatal:") && line.contains("could not read username") && line.contains("terminal prompts disabled") })`. Keep `#[must_use]` and the signature. 5. [ ] Run `cargo test -p bibcode-server --lib sign_in:: -j 2`. Expected: PASS, including `detects_the_missing_username_refusal_only_with_prompts_disabled` and the push-guidance tests in `git/manager/operations/sign_in_tests.rs` run by `cargo test -p bibcode-server --lib sign_in_tests -j 2`. -6. [ ] Prove the test can fail: remove the `starts_with("fatal:")` condition, then run `cargo test -p bibcode-server --lib sign_in::tests::the_missing_username_refusal_needs_a_fatal_line -j 2`. Expected: FAIL. Restore the condition and run it again. Expected: PASS. +6. [ ] Prove the test can fail: revert this task's change by hand (put the old two-`contains` body back, keeping the test) and run `cargo test -p bibcode-server --lib sign_in::tests::the_missing_username_refusal_needs_a_fatal_line -j 2`. Expected: FAIL on assertion (a) or (b). Restore the anchored detector and run it again. Expected: PASS. 7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 2.4: Wait for running lookups at quiesce -- Satisfies: R26: quiesce waits for every running lookup before the profiler teardown; a lookup admitted after shutdown ends at once as cancelled. +- Satisfies: R26: quiesce waits for every running lookup before the profiler teardown, whether or not its cache entry still exists; a lookup admitted after shutdown ends at once as cancelled. - Depends on: none -- Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/source_control/pull_request.rs`; modify `apps/server/src/production/git_vcs.rs`; modify `apps/server/src/production/runtime.rs`; test `apps/server/src/source_control/open_pull_request_lookups.rs` and `apps/server/src/production/runtime.rs` +- Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/source_control/pull_request.rs`; modify `apps/server/src/production/git_vcs.rs`; modify `apps/server/src/production/runtime.rs`; test `apps/server/src/source_control/open_pull_request_lookups.rs` and `apps/server/src/production/git_vcs.rs` - Interfaces: - - consumes `OpenPullRequestLookups::admit` (the bare `tokio::spawn` of the shared lookup), `Slot::InFlight { id, lease, result }`, `PullRequestService.open_lookups: Arc`, `tokio_util::task::TaskTracker` - - produces `OpenPullRequestLookups::shutdown(&self) -> impl Future` (`pub(crate) async fn shutdown(&self)`): marks the set closed, cancels the cancellation token of every in-flight lookup, closes a `TaskTracker` and waits for it; safe to call twice; after it, `resolve` returns `None` without calling `load` - - produces `PullRequestService::shutdown_open_pull_request_lookups(&self)` (`pub async fn`), calling the above + - consumes `OpenPullRequestLookups::admit` (the bare `tokio::spawn` of the shared lookup and the `flight_cancellation = CancellationToken::new()`), `FlightLease` (its `Drop` cancels the flight token), `Slot::InFlight { id, lease, result }`, `invalidate` (removes entries, in-flight ones included, so the flight keeps running with no entry), `make_room` and the capacity check (`state.entries.len() < state.capacity`; when full the flight runs with no entry), `OpenPullRequestLookups::resolve(self: &Arc, key: LookupKey, canonical_cwd: PathBuf, cancellation: &CancellationToken, load: F) -> Option`, `LookupResult = Result, SourceControlProviderError>`, `tokio_util::task::TaskTracker` + - produces a root `shutdown: CancellationToken` and a `tasks: TaskTracker` as fields of `OpenPullRequestLookups` (outside the `Mutex`). Every flight token is created as `self.shutdown.child_token()` in `admit`, before and independent of whether an entry is inserted, so shutdown reaches an invalidated flight and a flight started with no cache room. The lease still cancels only its own child token + - produces `pub(crate) async fn shutdown(&self)` on `OpenPullRequestLookups`: cancels `shutdown`, calls `tasks.close()`, then `tasks.wait().await`; safe to call twice + - the cancelled result is the existing type `Option` with the value `None`: `resolve` returns `None` when `shutdown` is cancelled (a new first `biased` arm `() = self.shutdown.cancelled() => None` in its `select!`, and `Admission::Closed` from `admit` once `shutdown.is_cancelled()`, without calling `load`). `PullRequestService::resolve_current_optional_shared` already maps `None` to its "The pull-request lookup was cancelled." `SourceControlProviderError`. A flight that ends after shutdown still sends the loader's own outcome on its watch channel, but no waiter reads it + - R27 (Task 2.5) never stores a cancelled flight's outcome: `settle` receives `cancelled = flight_cancellation.is_cancelled()`, which is true after a lease drop and after shutdown (a child token is cancelled with its parent) + - produces `PullRequestService::shutdown_open_pull_request_lookups(&self)` (`pub async fn`) calling the above - produces `GitVcsRpcServices::pull_requests(&self) -> PullRequestService` (`pub(crate)`, a clone; the runtime calls it after every builder, because `with_hosting_executable_dir_for_integration_test` replaces the service) + - every user of the shared cache, found with `rg "resolve_current_optional_shared|open_lookups"`: `lookup_open_pull_request` in `git_vcs.rs` (called from `vcs.refreshStatus`, its late callback, and the status stream's `start_lookup`); the `GitStatusSummaryService` holds a clone of the same `PullRequestService` and shares the `Arc`; `PullRequestService::invalidate_open_pull_request_lookups` (callers: `observe_local_changes`, the local-change closure near the `handle.spawn` site in `git_vcs.rs`, and `create_with_options` in `pull_request.rs`) stays valid after shutdown. `GitManagerRpcServices` builds its own `PullRequestService::default()` and never calls the shared lookup, so it needs no shutdown - produces the quiesce step name `pull-request-lookups`, marked with `steps.mark("pull-request-lookups")` directly after `steps.mark("status-broadcaster")` and before `worktree-catalog-operations`, so it runs before `git-writes` and before `profiler-end-session`. Phase 9 copies this exact name into `events.md` (position: after `status-broadcaster`) - Skills: `tdd` (new shutdown behavior), `bibcode-profiler` (stop sequence and teardown order must stay covered) -- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (module doc, `admit`, `settle`, the `Loader` test helper); `apps/server/src/production/runtime.rs` (`quiesce_for_update` from `mcp_control` to `profiler-end-session`, test `index_lock_cleanup_quiesce_stops_a_running_git_write`); `apps/server/src/source_control/pull_request.rs` (`resolve_current_optional_shared`, `run_provider_os_with_allowed_exit_codes`) +- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (module doc, `admit`, `settle`, `invalidate`, `make_room`, the `Loader` test helper whose gated mode returns `Err(failure())` at once on cancellation); `apps/server/src/production/runtime.rs` (`quiesce_for_update` from `mcp_control` to `profiler-end-session`); `apps/server/src/source_control/pull_request.rs` (`resolve_current_optional_shared`, `run_provider_os_with_allowed_exit_codes`); `apps/server/src/production/git_vcs.rs` (`Fixture`, `FAKE_GH`, `a_forge_cli_is_running`, test `a_lookup_past_the_refresh_bound_stops_at_quiesce`) - Tier: most capable - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::shutdown_ -j 2` exits 0 with at least 3 tests, and `cargo test -p bibcode-server --lib production::runtime::tests::quiesce_in_ -j 2` exits 0 including the new `quiesce_in_waits_for_a_running_pull_request_lookup` +- Done when: `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_` exits 0 with five tests passed (`shutdown_waits_for_a_lookup_that_ends_late`, `shutdown_stops_an_invalidated_flight`, `shutdown_stops_a_flight_that_had_no_cache_room`, `shutdown_ends_a_retained_waiter_as_cancelled`, `shutdown_refuses_a_new_lookup`), `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::pull_request_lookup_shutdown_` exits 0 with one test passed, and the order check in step 10 prints the four names in order Steps -1. [ ] Settle where the `gh` child is reaped. Finding from the code read for this plan: the flight future calls `PullRequestService::resolve_current_optional`, which awaits `self.runner.run(ProcessRequest { .. }, cancellation)` in `run_provider_os_with_allowed_exit_codes`. The supervised runner kills and reaps the child inside that awaited call when the flight's token is cancelled. So the child is reaped inside the detached flight task, and joining that task is enough; nothing else needs tracking. Record this finding in the doc comment of `OpenPullRequestLookups::shutdown`. Re-read both functions; if the runner instead hands the reap to a background task, join that too and say so in the comment. -2. [ ] Write the failing tests in the `tests` module of `open_pull_request_lookups.rs`: Expected: the tests below compile and are the ones the next step runs. - - `shutdown_waits_for_a_lookup_that_ends_late`: a loader that, once its flight token is cancelled, waits 200 ms (use `tokio::time::sleep`, paused clock, `#[tokio::test(start_paused = true)]`) and then sets an `ended` flag; start `resolve` on a spawned task, wait for the run, call `lookups.shutdown().await`; assert `ended` is true when `shutdown` returns. - - `shutdown_cancels_a_lookup_nobody_leaves`: the waiter is still waiting when `shutdown` is called; assert the loader saw its cancellation and the waiter got `None`. - - `a_lookup_admitted_after_shutdown_ends_as_cancelled`: after `shutdown`, `resolve` returns `None` and `loader.runs() == 0`. -3. [ ] Write the failing runtime test `quiesce_in_waits_for_a_running_pull_request_lookup` in the `tests` module of `production/runtime.rs`, copying the shape of `index_lock_cleanup_quiesce_stops_a_running_git_write`: start a lookup through `runtime`'s `GitVcsRpcServices::pull_requests()` with a fake forge command that ignores its first TERM for 200 ms, call `runtime.quiesce_for_update().await`, assert the fake command has exited and `quiesce_for_update` returned `Ok`. Gate with `#[cfg(unix)]` like its neighbors. -4. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::shutdown_ -j 2`. Expected: FAIL to compile (`no method named shutdown`). Add a stub `pub(crate) async fn shutdown(&self) {}` and run again. Expected: FAIL on `ended` being false. -5. [ ] Implement in `open_pull_request_lookups.rs`: add `tasks: TaskTracker` and a `closed` flag in `State`; store each flight's `CancellationToken` in `Slot::InFlight`; spawn the shared lookup with `self.tasks.spawn(..)` instead of `tokio::spawn(..)` (the uncoalesced no-room path goes through the same spawn); in `admit`, when `closed` is true return a new `Admission::Closed`, which `resolve` maps to `None` before `load` is called; `shutdown` sets `closed`, cancels every in-flight token, calls `tasks.close()`, then `tasks.wait().await`. Add `PullRequestService::shutdown_open_pull_request_lookups` in `pull_request.rs` and `GitVcsRpcServices::pull_requests` in `git_vcs.rs`. -6. [ ] Implement in `runtime.rs`: add a field `pull_requests: crate::source_control::PullRequestService` to the runtime struct, filled from `git_vcs.pull_requests()` after the last builder call; in `quiesce_for_update`, after `self.status_broadcaster.shutdown().await; steps.mark("status-broadcaster");` add `self.pull_requests.shutdown_open_pull_request_lookups().await; steps.mark("pull-request-lookups");`. Order after the status broadcaster is deliberate: streams that hold leases are stopped first, so the cancelled lookups have no waiters left. -7. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2` and `cargo test -p bibcode-server --lib production::runtime::tests::quiesce_in_ -j 2`. Expected: PASS. Also run `cargo test -p bibcode-server --lib production::git_vcs::tests::a_lookup_past_the_refresh_bound_stops_at_quiesce -j 2`. Expected: PASS. -8. [ ] Prove the tests can fail: replace `self.tasks.spawn(..)` with `tokio::spawn(..)`, then run `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::shutdown_waits_for_a_lookup_that_ends_late -j 2`. Expected: FAIL. Restore it and run again. Expected: PASS. -9. [ ] Check the order in the source: `rg -n 'steps.mark\("(status-broadcaster|pull-request-lookups|git-writes|profiler-end-session)"\)' apps/server/src/production/runtime.rs`. Expected: the four names in exactly that order. -10. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. +1. [ ] Settle where the `gh` child is reaped. Finding from the code read for this plan: the flight future calls `PullRequestService::resolve_current_optional`, which awaits `self.runner.run(ProcessRequest { .. }, cancellation)` in `run_provider_os_with_allowed_exit_codes`. That runner stops a non-Git child by force (stop policy `Force` in `git/process.rs`), so the child dies at the cancel and is reaped inside that awaited call, in the detached flight task. Joining the task is therefore enough. Record this in the doc comment of `shutdown`. Do not use a `TERM` trap to model a slow exit: it has no effect here. +2. [ ] Extend the `Loader` helper in the `tests` module: a field `cleanup: Duration` (default zero) and an `ended: Arc`; in gated mode, when the flight token is cancelled the loader does `tokio::time::sleep(self.cleanup).await`, increments `ended`, and returns `Err(failure())`. This is the seam: `load` is the closure the service hands the cache, so the delayed cleanup is injected without a real process. Add `#[cfg(test)] fn entry_count_for_test(&self) -> usize` on `OpenPullRequestLookups`. +3. [ ] Write the failing tests in `open_pull_request_lookups.rs`, each `#[tokio::test(start_paused = true)]` (the 200 ms is virtual time, advanced by the runtime when every task is idle): Expected: the change is in place, and the next command step checks it. + - `shutdown_waits_for_a_lookup_that_ends_late`: a gated loader with `cleanup` 200 ms; spawn `resolve`, wait for the run, record `start = tokio::time::Instant::now()`, `lookups.shutdown().await`; assert `ended == 1` and `start.elapsed() >= 200 ms`. + - `shutdown_stops_an_invalidated_flight`: spawn `resolve` with the same loader, wait for the run, call `lookups.invalidate(path)` (the entry is gone), then `shutdown`; assert the loader saw its cancellation (`cancelled == 1`) and `ended == 1`. + - `shutdown_stops_a_flight_that_had_no_cache_room`: `OpenPullRequestLookups::with_limits(OPEN_PULL_REQUEST_LOOKUP_TTL, 0)` so no entry is stored; spawn `resolve`, wait for the run, `shutdown`; assert `cancelled == 1`, `ended == 1` and `entry_count_for_test() == 0`. + - `shutdown_ends_a_retained_waiter_as_cancelled`: the waiter's own cancellation token is never cancelled and it still holds its lease when `shutdown` is called; assert the waiter's `resolve` returns `None` and `shutdown` returned. + - `shutdown_refuses_a_new_lookup`: after `shutdown`, `resolve` returns `None` and `loader.runs() == 0`; a second `shutdown().await` returns at once. +4. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_`. Expected: FAIL to compile (`no method named shutdown`). Add `pub(crate) async fn shutdown(&self) {}` and `entry_count_for_test`, run again. Expected: five FAILs (`ended` is 0, `resolve` returns `Some(..)`). +5. [ ] Implement in `open_pull_request_lookups.rs` as listed in Interfaces. The spawn becomes `self.tasks.spawn(..)` and covers the no-room path too. `settle` is untouched here; Task 2.5 adds the `cancelled` parameter. +6. [ ] Add `PullRequestService::shutdown_open_pull_request_lookups` in `pull_request.rs` and `GitVcsRpcServices::pull_requests` in `git_vcs.rs`. +7. [ ] Write the failing wiring test `pull_request_lookup_shutdown_reaps_the_forge_cli` in the `tests` module of `git_vcs.rs` (Unix-only like its neighbors): `Fixture::new("pr-lookup-shutdown")`, `fixture.set("slow", "30")`, start a lookup with `fixture.refresh()` under `tokio::time::timeout(Duration::from_secs(5), ..)` so it returns at its bound while the fake `gh` keeps running, then `fixture.services.pull_requests().shutdown_open_pull_request_lookups().await`; immediately assert `!fixture.a_forge_cli_is_running()` (no polling: shutdown returned only after the reap). Run `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::pull_request_lookup_shutdown_`. Expected before step 6 is done: FAIL to compile; after steps 5 and 6: PASS. Expected: the change is in place, and the next command step checks it. +8. [ ] In `runtime.rs` add a field `pull_requests: crate::source_control::PullRequestService` to the runtime struct, filled from `git_vcs.pull_requests()` after the last builder call, and in `quiesce_for_update`, after `self.status_broadcaster.shutdown().await; steps.mark("status-broadcaster");`, add `self.pull_requests.shutdown_open_pull_request_lookups().await; steps.mark("pull-request-lookups");`. The order after the status broadcaster is deliberate: streams that hold leases stop first. +9. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: quiesce_in_ production::git_vcs::tests::a_lookup_past_the_refresh_bound_stops_at_quiesce`. Expected: PASS (all three filters match tests). +10. [ ] Check the order in the source: `rg -n 'steps.mark\("(status-broadcaster|pull-request-lookups|git-writes|profiler-end-session)"\)' apps/server/src/production/runtime.rs`. Expected: the four names in exactly that order. +11. [ ] Prove the tests can fail: revert this task's implementation edits by hand (no `git stash`) back to the step 4 stubs and the bare `tokio::spawn`, keeping the tests, and rerun `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_`. Expected: FAIL. Restore the implementation and rerun. Expected: PASS. +12. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 2.5: Remember a failed lookup for 5 seconds -- Satisfies: R27 (D6): the same key answers with the failure for 5 seconds without a new lookup; a cancelled lookup is never remembered as failed; an invalidation clears it; the failure is logged once at `warn` without the remote's URL. +- Satisfies: R27 (D6): the same key answers with the failure for 5 seconds without a new lookup; a cancelled lookup (a lease drop or shutdown) is never remembered as failed; an invalidation clears it; the failure is logged once at `warn` without the remote's URL. - Depends on: 2.4 (same file and the same `Slot` enum) - Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/production/git_vcs.rs` (test `a_failed_lookup_is_not_remembered_as_no_pull_request` only); test `apps/server/src/source_control/open_pull_request_lookups.rs` -- Interfaces: consumes `Slot`, `settle(&self, key: &LookupKey, id: u64, outcome: &LookupResult)`, `LookupResult = Result, SourceControlProviderError>` (the error is `Clone`); produces `pub(crate) const OPEN_PULL_REQUEST_FAILURE_BACKOFF: Duration = Duration::from_secs(5)`, a new variant `Slot::Failed { error: SourceControlProviderError, expires_at: Instant }`, a new variant `Admission::Failed(SourceControlProviderError)` that `resolve` returns as `Some(Err(error))`. `with_limits(ttl, capacity)` keeps its signature +- Interfaces: consumes `Slot`, `settle(&self, key: &LookupKey, id: u64, outcome: &LookupResult)`, `make_room`, `LookupResult` (its error `SourceControlProviderError` is `Clone`), the `Loader` helper and `entry_count_for_test` from Task 2.4; produces `pub(crate) const OPEN_PULL_REQUEST_FAILURE_BACKOFF: Duration = Duration::from_secs(5)`, `Slot::Failed { error: SourceControlProviderError, expires_at: Instant }`, `Admission::Failed(SourceControlProviderError)` that `resolve` returns as `Some(Err(error))`, and `settle(&self, key, id, outcome, cancelled: bool)`. `with_limits(ttl, capacity)` keeps its signature. The only caller of `settle` is the flight task in `admit` - Skills: `tdd` (new backoff behavior), `bibcode-profiler` (the warn record must hold no URL, path or branch) -- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (`admit`, `settle`, `make_room`, test `failures_are_shared_with_waiters_but_never_stored`); `apps/server/src/production/git_vcs.rs` (test `a_failed_lookup_is_not_remembered_as_no_pull_request`) +- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (`admit`, `settle`, `make_room`, tests `failures_are_shared_with_waiters_but_never_stored` and `one_waiter_leaving_does_not_cancel_the_shared_lookup_but_the_last_one_does`); `apps/server/src/production/git_vcs.rs` (test `a_failed_lookup_is_not_remembered_as_no_pull_request`) - Tier: most capable - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2` exits 0 with the new tests listed, and `cargo test -p bibcode-server --lib production::git_vcs::tests::a_failed_lookup -j 2` exits 0 +- Done when: `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests:: production::git_vcs::tests::a_failed_lookup` exits 0 with the new tests listed Steps -1. [ ] Write the failing tests in `open_pull_request_lookups.rs`, all with `#[tokio::test(start_paused = true)]`: - - `a_failed_lookup_answers_from_memory_for_five_seconds`: first `resolve` returns `Some(Err(failure()))` with `loader.runs() == 1`; a second `resolve` at +4 s returns `Some(Err(failure()))` with `runs() == 1`; at +5 s plus 1 ms a third `resolve` runs the loader again (`runs() == 2`). - - `a_cancelled_lookup_is_not_remembered_as_failed`: a gated loader whose waiter is cancelled so the flight token is cancelled and the loader returns `Err(failure())`; the next `resolve` runs the loader (`runs() == 2`). - - `an_invalidation_clears_a_remembered_failure`: after a failure, `lookups.invalidate(path)`, then `resolve` runs the loader again. - - `a_failed_lookup_warns_once_without_the_remote_address`: capture `tracing` output at `WARN` with `tracing::subscriber::set_default` and a local `CapturedLog` writer (copy the shape used in the `tests` module of `production/runtime.rs`); a failure whose `detail` and `cwd` hold `https://gitlab.example.test/example-group/sample-project` and `/work/sample-project` is resolved twice inside the backoff; assert exactly one WARN line, and that it contains neither `gitlab.example.test` nor `sample-project`. -2. [ ] Update the existing test `failures_are_shared_with_waiters_but_never_stored`: it must keep proving that waiters share one run, and its last assertion changes from "a failure must not be remembered" to `recovered.runs() == 0` inside 5 s, then `recovered.runs() == 1` after `tokio::time::advance(Duration::from_secs(5))`. Update `a_failed_lookup_is_not_remembered_as_no_pull_request` in `git_vcs.rs` to call `fixture.services.pull_requests.invalidate_open_pull_request_lookups(&fixture.repository)` between `clear("fail")` and the second refresh (a push, a mutation or a local change does this in production); its name and meaning stay: a failure is not stored as "no pull request". Expected: both updated tests compile and fail in the next run until the backoff exists. -3. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2`. Expected: FAIL on `a_failed_lookup_answers_from_memory_for_five_seconds` with `left: 2, right: 1` (the second call ran the loader). -4. [ ] Implement: in `settle`, take `cancelled: bool` (the flight token's `is_cancelled()` read in the spawned task before `settle`); for `Err(error)` with `!cancelled` and the key still owned by this flight, set `Slot::Failed { error: error.clone(), expires_at: Instant::now() + OPEN_PULL_REQUEST_FAILURE_BACKOFF }`; with `cancelled`, remove the entry as today. In `admit`, a `Slot::Failed` whose `expires_at > now` returns `Admission::Failed(error.clone())`; an expired one falls through to a new lookup. In `make_room`, keep an unexpired `Failed` entry and count it among the entries removed by expiry or by the closest-to-expiring rule. Log in the spawned task, once per lookup that ended with an uncancelled `Err`: `tracing::warn!(provider = ?key.provider, "open pull-request lookup failed; answering with the failure for 5 seconds")`. Do not log the error, cwd, reference or command. -5. [ ] Run `cargo test -p bibcode-server --lib open_pull_request_lookups:: -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_ -j 2`. Expected: PASS (`pr_row_a_failed_lookup_keeps_the_pull_request` still passes: the stream shows the request after a failure). -6. [ ] Prove the tests can fail: make `settle` store `Slot::Failed` for a cancelled flight too, then run `cargo test -p bibcode-server --lib open_pull_request_lookups::tests::a_cancelled_lookup_is_not_remembered_as_failed -j 2`. Expected: FAIL. Restore it and run again. Expected: PASS. +1. [ ] Write the failing tests in `open_pull_request_lookups.rs`, all `#[tokio::test(start_paused = true)]` so time is the paused clock: + - `a_failed_lookup_answers_from_memory_for_five_seconds`: first `resolve` returns `Some(Err(failure()))` with `loader.runs() == 1`; after `tokio::time::advance(4 s)` a second `resolve` returns `Some(Err(failure()))` with `runs() == 1`; after a further `advance(1 s + 1 ms)` a third `resolve` runs the loader (`runs() == 2`). + - `a_cancelled_lookup_is_not_remembered_as_failed`: a gated loader; the only waiter is cancelled so the lease drops and the flight token is cancelled, and the loader returns `Err(failure())`. Wait for the flight to settle before retrying: loop `tokio::task::yield_now()` until `loader.cancelled == 1` and `lookups.entry_count_for_test() == 0` (a wrongly stored failure would leave one entry). Only then call `resolve` again; assert it runs the loader (`runs() == 2`). + - `a_shutdown_flight_is_not_remembered_as_failed`: after the cancelled flight of a `shutdown`, `entry_count_for_test() == 0` (use a lookups value with capacity 64 and a gated loader; settle is awaited by `shutdown`). + - `an_invalidation_clears_a_remembered_failure`: after a failure, `lookups.invalidate(path)`, then `resolve` runs the loader again within the 5 s. + - `a_failed_lookup_warns_once_without_the_remote_address`: capture `tracing` output at `WARN` with `tracing::subscriber::set_default` and a local writer type shaped like the `CapturedLog` in `production/mcp_tools_tests/git.rs`; a failure whose `detail` and `cwd` hold `https://gitlab.example.test/example-group/sample-project` and `/work/sample-project` is resolved twice inside the backoff; assert exactly one WARN line and that it contains neither `gitlab.example.test` nor `sample-project`. +2. [ ] Rewrite the existing tests that conflict with the backoff. `failures_are_shared_with_waiters_but_never_stored` is today an unpaused `#[tokio::test]` with a real 50 ms `sleep`: change it to `#[tokio::test(start_paused = true)]` and replace that sleep with `tokio::time::advance(Duration::from_millis(50))`; it keeps proving that the second caller joined the running lookup (`loader.runs() == 1`); its last assertion becomes `recovered.runs() == 0` inside 5 s (the failure answers from memory) and `recovered.runs() == 1` after `tokio::time::advance(OPEN_PULL_REQUEST_FAILURE_BACKOFF)`. Rename it `failures_are_shared_with_waiters_and_remembered_for_five_seconds`. In `git_vcs.rs`, `a_failed_lookup_is_not_remembered_as_no_pull_request` keeps its name and meaning (a failure is not stored as "no pull request") and calls `fixture.services.pull_requests().invalidate_open_pull_request_lookups(&fixture.repository)` between `clear("fail")` and the second refresh: a push, a mutation or a local change does this in production. That test uses the real clock and a real fake `gh`, so it must not advance time. +3. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::`. Expected: FAIL on `a_failed_lookup_answers_from_memory_for_five_seconds` with `left: 2, right: 1` (the second call ran the loader). +4. [ ] Implement: `settle` takes `cancelled: bool`, passed from the flight task as `flight_cancellation.is_cancelled()` read after `lookup.await`. For `Err(error)` with `!cancelled` and the key still owned by this flight, set `Slot::Failed { error: error.clone(), expires_at: Instant::now() + OPEN_PULL_REQUEST_FAILURE_BACKOFF }`; with `cancelled`, remove the entry as today. In `admit`, an unexpired `Slot::Failed` returns `Admission::Failed(error.clone())`; an expired one falls through to a new lookup. In `make_room`, keep an unexpired `Failed` entry and let expiry and the closest-to-expiring rule remove it. Log in the flight task once per lookup that ended with an uncancelled `Err`: `tracing::warn!(provider = ?key.provider, "open pull-request lookup failed; answering with the failure for 5 seconds")`. Do not log the error, cwd, reference or command. +5. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: production::git_vcs::tests::pr_row_ production::git_vcs::tests::a_failed_lookup`. Expected: PASS (`pr_row_a_failed_lookup_keeps_the_pull_request` still passes: the stream keeps showing the request after a failure). +6. [ ] Prove the tests can fail: revert this task's implementation edits by hand (`settle` back to removing the entry on `Err`, no `Slot::Failed`, no log), keeping the tests, and rerun `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::`. Expected: FAIL. Restore the implementation and rerun. Expected: PASS. 7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 2.6: Stop showing a remembered pull request after a failed lookup on stale data -- Satisfies: R28 (D7): a failed lookup, with the last success for that branch more than 5 minutes old, clears the remembered request; without a failed lookup it stays however old; a ref change clears it as today. +- Satisfies: R28 (D7): a failed lookup, with the last success for that branch more than 5 minutes old, clears the remembered request, on every path that completes a lookup (the refresh, the status stream, and the late lookup that a refresh hands to subscribers); without a failed lookup it stays however old; a ref change clears it as today. - Depends on: 2.5 (the failure signal is the same; both edit `git_vcs.rs`) -- Files: modify `apps/server/src/git/broadcaster.rs`; modify `apps/server/src/production/git_vcs.rs`; test `apps/server/src/git/broadcaster.rs` +- Files: modify `apps/server/src/git/broadcaster.rs`; modify `apps/server/src/production/git_vcs.rs`; test `apps/server/src/git/broadcaster.rs` and `apps/server/src/production/git_vcs.rs` - Interfaces: - - consumes `GitBroadcaster::record_pull_request(&self, cwd: &Path, ref_name: Option<&str>, pull_request: Option)`, `known_pull_request`, `RepositoryState`, `clear_remote_for_ref_change`, and in `git_vcs.rs` `lookup_open_pull_request(...) -> Option>` whose `None` means failed or cancelled + - consumes `StatusBroadcaster::record_pull_request(&self, cwd: &Path, ref_name: Option<&str>, pull_request: Option)`, `known_pull_request`, `notify_pull_request_change(&self, cwd: &Path)` (async), `RepositoryState`, `clear_remote_for_ref_change`, `keep_pull_request`; in `git_vcs.rs`, `lookup_open_pull_request(...) -> Option>` whose `None` means failed or cancelled. Every site that handles a lookup's result, found with `rg "lookup_open_pull_request|record_pull_request" apps/server/src/production/git_vcs.rs`: (1) the `Ok(None)` arm of `vcs.refreshStatus`; (2) the late callback inside `self.broadcaster.spawn_for_subscribers(&cwd, async move { if let Some(pull_request) = lookup.await { .. } })`, which today handles only success; (3) the `found` arm of the status stream loop. `record_pull_request` has these three writers and no other; `keep_pull_request` copies a cached request into a newer remote without a lookup and is not a success - produces `RepositoryState.pr_recorded_at: Option`, set by `record_pull_request` on every success (including "no open request") and cleared in `clear_remote_for_ref_change` - produces `pub(crate) const PULL_REQUEST_STALE_AFTER: Duration = Duration::from_secs(300)` in `broadcaster.rs` - - produces `pub(crate) fn note_failed_pull_request_lookup(&self, cwd: &Path, ref_name: Option<&str>)`: when `ref_name` matches, `pr_recorded_at` is older than `PULL_REQUEST_STALE_AFTER` and a request is remembered, sets the remembered `remote.pr` to `None`; then it requests a republish the way `notify_pull_request_change` does so streams show the cleared state + - produces `pub(crate) fn note_failed_pull_request_lookup(&self, cwd: &Path, ref_name: Option<&str>) -> bool`: returns `false` and changes nothing when the broadcaster is closed, `ref_name` does not match, no request is remembered, or `pr_recorded_at` is younger than `PULL_REQUEST_STALE_AFTER`; otherwise sets the remembered `remote.pr` to `None` and returns `true`. It publishes nothing; callers decide + - produces `#[cfg(test)] pub(crate) fn age_pull_request_for_test(&self, cwd: &Path, age: Duration)` that moves `pr_recorded_at` back by `age` (`checked_sub`, so a machine up for under `age` panics with a clear message), for the stream tests that run on the real clock - Skills: `tdd` (new expiry behavior) -- Read first: `apps/server/src/git/broadcaster.rs` (`record_pull_request`, `known_pull_request`, `keep_pull_request`, `clear_remote_for_ref_change`, test `a_snapshot_under_a_new_epoch_keeps_the_known_pull_request`); `apps/server/src/production/git_vcs.rs` (the `Ok(None)` and `Err(_)` arms of `vcs.refreshStatus`, and the `found` arm of the status stream loop) +- Read first: `apps/server/src/git/broadcaster.rs` (`record_pull_request`, `known_pull_request`, `keep_pull_request`, `clear_remote_for_ref_change`, test `a_snapshot_under_a_new_epoch_keeps_the_known_pull_request`); `apps/server/src/production/git_vcs.rs` (the `Ok(None)` and `Err(_)` arms of `vcs.refreshStatus`, the `found` arm of the stream loop, `ShownStatus::reconcile`, tests `a_lookup_past_the_refresh_bound_reaches_the_subscriber` and `pr_row_a_failed_lookup_keeps_the_pull_request`) - Tier: most capable - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_ -j 2` exits 0 with the new tests listed, and `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_row_ -j 2` exits 0 +- Done when: `cargo test -p bibcode-server --lib -j 2 -- git::broadcaster::tests::a_failed_lookup_ production::git_vcs::tests::pr_row_` exits 0 with the new tests listed Steps -1. [ ] Decide how the callers tell failure from cancellation, and write it in the doc comment of `note_failed_pull_request_lookup`. In the status stream, `found` is `Ok(None)` for both a failed lookup and one cancelled by `task_token`; count it as a failure only when `finished.cancellation.is_cancelled()` is false. In `vcs.refreshStatus`, the inner lookup runs under a token nothing cancels, so `Ok(None)` from the timeout wrapper's inner result is a failure; the outer `cancellation.cancelled()` arm produces `Ok(None)` of the select (not the lookup) and is not a failure. Re-read both arms and adjust if the shapes differ. -2. [ ] Write the failing tests in `broadcaster.rs` (`#[tokio::test(start_paused = true)]`, copying the setup of `a_snapshot_under_a_new_epoch_keeps_the_known_pull_request`): - - `a_failed_lookup_clears_a_request_recorded_six_minutes_ago`: record a request, `tokio::time::advance(360 s)`, call `note_failed_pull_request_lookup`; `known_pull_request` returns `None`. - - `a_failed_lookup_keeps_a_request_recorded_four_minutes_ago`: same with 240 s; still `Some`. +1. [ ] Decide how the callers tell failure from cancellation and write it in the doc comment of `note_failed_pull_request_lookup`. Stream arm: `found` is `Ok(None)` for a failed lookup and also for one cancelled through `task_token`; count it as a failure only when `finished.cancellation.is_cancelled()` is false. Refresh arm: the inner lookup runs under a token nothing cancels, so the lookup's own `None` is a failure; the select's `cancellation.cancelled()` arm is not the lookup's result and is not a failure. Late callback: `lookup.await` returning `None` after shutdown is a cancelled lookup, and `note_failed_pull_request_lookup` returns `false` when the broadcaster is closed, so shutdown clears nothing. Re-read the three sites and adjust if the shapes differ. +2. [ ] Write the failing tests in `broadcaster.rs` (`#[tokio::test(start_paused = true)]`, copying the setup of `a_snapshot_under_a_new_epoch_keeps_the_known_pull_request`; its fixtures use the real `StatusBroadcaster`, no component is replaced): + - `a_failed_lookup_clears_a_request_recorded_six_minutes_ago`: record a request, `tokio::time::advance(360 s)`, `note_failed_pull_request_lookup` returns `true`; `known_pull_request` returns `None`. + - `a_failed_lookup_keeps_a_request_recorded_four_minutes_ago`: same with 240 s; returns `false`; still `Some`. - `a_request_recorded_six_minutes_ago_stays_without_a_failed_lookup`: advance 360 s and call only `known_pull_request`; `Some`. - - `a_failed_lookup_for_a_former_branch_clears_nothing`: `ref_name` differs; still `Some`. - - `a_ref_change_clears_the_recorded_time`: after `clear_remote_for_ref_change`, a later failure on the new ref has no stale stamp to compare (no request shown, no panic). -3. [ ] Run `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_ -j 2`. Expected: FAIL to compile (`no method named note_failed_pull_request_lookup`). Add a stub that does nothing and run again. Expected: FAIL on the six-minute test (the request is still `Some`). -4. [ ] Implement in `broadcaster.rs` the field, the constant and the method from Interfaces; `record_pull_request` sets `entry.pr_recorded_at = Some(Instant::now())` under the same `ref_name` filter it already applies. `keep_pull_request` needs no change: it copies a cached request only when the new remote has none, and the cache now holds `None` after expiry. -5. [ ] Call `note_failed_pull_request_lookup` from both `git_vcs.rs` sites chosen in step 1, in the stream arm before `shown.reconcile`, and in `vcs.refreshStatus` where `Ok(None)` is matched. -6. [ ] Add a stream test next to `pr_row_a_failed_lookup_keeps_the_pull_request` named `pr_row_a_failed_lookup_after_six_minutes_clears_the_pull_request`: `Fixture` with a paused or advanced clock, the subscriber shows request 42, advance 6 minutes, `fixture.set("fail", "")`, `fixture.services.pull_requests.invalidate_open_pull_request_lookups(..)`, `fixture.report_pull_request_change()`; the subscriber later shows `pr().is_null()`. If `Fixture` cannot advance the broadcaster's clock, add a `#[cfg(test)]` setter `set_pull_request_recorded_at_for_test(&self, cwd, Instant)` on `GitBroadcaster` and use it. -7. [ ] Run `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_ -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_row_ -j 2`. Expected: PASS. -8. [ ] Prove the tests can fail: change the comparison to `>=` the wrong way (clear when the stamp is younger than 300 s), then run `cargo test -p bibcode-server --lib git::broadcaster::tests::a_failed_lookup_keeps_a_request_recorded_four_minutes_ago -j 2`. Expected: FAIL. Restore it and run again. Expected: PASS. + - `a_failed_lookup_for_a_former_branch_clears_nothing`: `ref_name` differs; `false`; still `Some`. + - `a_failed_lookup_after_a_ref_change_clears_nothing`: after `clear_remote_for_ref_change`, the stamp is gone; `false`, no panic. + - `a_failed_lookup_after_shutdown_clears_nothing`: after `shutdown().await`, `false`. +3. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- git::broadcaster::tests::a_failed_lookup_`. Expected: FAIL to compile (`no method named note_failed_pull_request_lookup`). Add a stub returning `false` and run again. Expected: FAIL on the six-minute test. +4. [ ] Implement in `broadcaster.rs` the field, the constant, the two methods from Interfaces, and the stamp in `record_pull_request` (under the same `ref_name` filter it already applies). +5. [ ] Call `note_failed_pull_request_lookup` from the three `git_vcs.rs` sites chosen in step 1. In the refresh arm and the stream arm, call it before the status is built and `shown.reconcile` runs, which then sends the cleared state. In the late callback, add an `else` branch: `if broadcaster.note_failed_pull_request_lookup(&late_cwd, late_ref.as_deref()) { broadcaster.notify_pull_request_change(&late_cwd).await; }`; the notify republishes the remote so the subscriber shows the cleared state. That republish makes each stream look up again, which the 5-second failure backoff of Task 2.5 answers from memory; the second failure finds nothing to clear and returns `false`, so no loop forms. +6. [ ] Add three stream tests in the `tests` module of `git_vcs.rs`, next to `pr_row_a_failed_lookup_keeps_the_pull_request`. They run on the real clock with the fake `gh`, so they age the stamp with `fixture.services.broadcaster.age_pull_request_for_test(&canonical_repository, Duration::from_secs(360))` (the same canonical path the broadcaster keys on): Expected: the change is in place, and the next command step checks it. + - `pr_row_a_failed_lookup_after_six_minutes_clears_the_pull_request`: `subscriber_showing_the_pull_request`, age the stamp, `fixture.set("fail", "")`, invalidate the lookups, `fixture.report_pull_request_change()`; the subscriber later shows `pr().is_null()`. + - `pr_row_a_failed_lookup_after_four_minutes_keeps_the_pull_request`: same with 240 s; after `subscriber.drain(..)` every shown value is 42. + - `pr_row_a_late_failed_lookup_after_six_minutes_clears_the_pull_request`: the refresh-past-its-bound shape of `a_lookup_past_the_refresh_bound_reaches_the_subscriber`: the subscriber shows 42, age the stamp, set the markers `slow` = `6` and `fail`, call `fixture.forget_lookups()`, then `fixture.refresh()` under a 5 s timeout (it answers at its bound with the remembered request), and the subscriber later shows `pr().is_null()` once the late lookup fails. The fake `gh` in `FAKE_GH` exits at `fail` before it reaches `slow`, so this needs a harness change: move the `if [ -n "$slow" ]; then sleep "$slow"; fi` line above the `fail` line. Every existing test sets only one of the two markers, so none changes behavior. +7. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- git::broadcaster::tests::a_failed_lookup_ production::git_vcs::tests::pr_row_`. Expected: PASS. +8. [ ] Prove the tests can fail: revert this task's implementation edits by hand (the stub returning `false` from step 3, and remove the three call sites), keeping the tests, and rerun the step 7 command. Expected: FAIL (the six-minute tests and the late-callback test). Restore and rerun. Expected: PASS. 9. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 2.7: Record the limit at spawn_for_subscribers @@ -190,7 +204,7 @@ Steps - Satisfies: R30: a stream test invalidates during a lookup with a local change that alters no status; if the pre-invalidation answer is shown with no follow-up lookup, a follow-up lookup is started; otherwise the test stays as proof. - Depends on: 2.6 (same files) - Files: modify `apps/server/src/production/git_vcs.rs` (test; code only in the defect branch); modify `apps/server/src/git/broadcaster.rs` (defect branch only); test `apps/server/src/production/git_vcs.rs` -- Interfaces: consumes `Fixture`, `subscriber_showing_the_pull_request`, `wait_for_lookups`, `fixture.set`, `fixture.clear`, `GitBroadcaster::notify_local_change(&self, cwd: &Path)` (async), `GitBroadcaster::notify_pull_request_change(&self, cwd: &Path)` (async); produces the test `a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer` +- Interfaces: consumes `Fixture`, `subscriber_showing_the_pull_request`, `wait_for_lookups`, `fixture.set`, `fixture.clear`, `StatusBroadcaster::notify_local_change(&self, cwd: &Path)` (async), `StatusBroadcaster::notify_pull_request_change(&self, cwd: &Path)` (async); produces the test `a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer` - Skills: `mstack-diagnose` (the test decides between defect and no defect), `tdd` (defect branch only) - Read first: `apps/server/src/production/git_vcs.rs` (test `a_local_change_during_a_lookup_replaces_its_outdated_answer`, which writes `saved.txt` and so changes status; `observe_local_changes`; the `publication` arm of the status stream loop that sets `look_again`); `apps/server/src/git/broadcaster.rs` (`notify_local_change`, `reconcile_remote_after_local_publication`) - Tier: most capable @@ -202,7 +216,7 @@ Steps 1. [ ] Write the test by copying `a_local_change_during_a_lookup_replaces_its_outdated_answer` with one change: after `fixture.clear("open")` do not write `saved.txt`; call `fixture.services.broadcaster.notify_local_change(&fixture.repository).await` with the working tree untouched, so the local status is byte-identical. Assert that within the helper's wait the subscriber's `pr()` becomes null and `fixture.lookups()` equals `["main", "main", "main"]`. 2. [ ] Run `cargo test -p bibcode-server --lib production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer -j 2`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. 3. [ ] If the result is PASS: this is the no-defect branch. The status owner publishes a neutral change, or the stream loop starts a follow-up lookup, so nothing changes in code. Keep the test, add the sentence `Decided by test (R30): no defect; a status-neutral local change still starts one follow-up lookup.` to the test's doc comment, and stop. -4. [ ] If the result is FAIL because the subscriber keeps showing request 42 and the lookup count stays at 2: this is the defect branch (a neutral change publishes nothing, so `look_again` is never set). Fix at the first wrong decision: in `observe_local_changes` and the local-change closure in `GitVcsRpcServices`, after `invalidate_open_pull_request_lookups`, call `broadcaster.notify_pull_request_change(cwd)` for the observer path as the closure at the `handle.spawn` site already does, so every invalidation also asks each stream to look again. Re-run the test. Expected: PASS. +4. [ ] If the result is FAIL because the subscriber keeps showing request 42 and the lookup count stays at 2: this is the defect branch (a neutral change publishes nothing, so `look_again` is never set). Fix at the first wrong decision: in `observe_local_changes` and the local-change closure in `GitVcsRpcServices`, after `invalidate_open_pull_request_lookups`, call `broadcaster.notify_pull_request_change(cwd)` for the observer path as the closure at the `handle.spawn` site already does, so every invalidation also asks each stream to look again. The invalidation writers are `observe_local_changes` (no notify today; the fix goes here, cloning `broadcaster` into its closure), the local-change closure near the `handle.spawn` site (already notifies) and `create_with_options` in `pull_request.rs` (its callers notify). Re-run the test. Expected: PASS. 5. [ ] In the defect branch also run `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_ -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::a_ -j 2`. Expected: PASS (no other stream test now counts an extra lookup; if one does, change its expected `fixture.lookups()` only where the extra lookup follows a real invalidation). -6. [ ] In the defect branch prove the test can fail: remove the added `notify_pull_request_change` call, then run the test. Expected: FAIL. Restore it and run again. Expected: PASS. +6. [ ] In the defect branch prove the test can fail: revert this task's code change by hand (remove the added `notify_pull_request_change` call, keeping the test) and run the test. Expected: FAIL. Restore it and run again. Expected: PASS. 7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md index da72155ab..43d4bb35a 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md @@ -7,7 +7,7 @@ - Must not touch: `docs/skills/bibcode-profiler/references/events.md` (Phase 9 documents `create-rollback`), `apps/server/src/git/**`, `apps/server/src/source_control/**`, `apps/server/src/runtime.rs` - Skills: `tdd` (every behavior task is red-green), `mstack-diagnose` (the flaky test task needs a proven cause), `bibcode-profiler` (the audit event and the Profiler coverage line) - Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data), `docs/architecture/overview.md` section "Profiler" (events and findings rules), `spec.md` in this plan folder (D9, D10, R13, R31 to R34) -- Checkpoint: `cargo test -p bibcode-server --lib worktree` and `cargo test -p bibcode-server --test repositories` and `cargo test -p bibcode-server --test profiler_worktree_audit` exit 0; `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0 +- Checkpoint: `cargo test -p bibcode-server --lib -- worktree_audit removal_ creation_record create_rollback` and `cargo test -p bibcode-server --test repositories` and `cargo test -p bibcode-server --test profiler_worktree_audit` exit 0; `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0 Writers set `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3` and put the pinned 1.98.1 toolchain first on PATH. All tasks edit `worktree_catalog_rpc.rs`, so one writer runs them in order. The 50 loaded runs and the 12 Windows VM suite runs for R13 belong to Phase 8. @@ -42,47 +42,60 @@ Steps - Satisfies: R31. Scenario: a worktree recreated at the same path between resolve and delete keeps its new record. - Depends on: none (run after 3.1: same file) - Files: modify `apps/server/src/persistence/repositories.rs`, `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/tests/repositories.rs`, `apps/server/src/production/orchestration_effects.rs` (caller only, if it needs any change); test the same files -- Interfaces: consumes `Repositories::reap_worktree_creation_record(&self, record_id: i64) -> Result<()>`, `Repositories::list_worktree_creation_records`. Produces: `Repositories::record_worktree_creation(&self, project_cwd: &str, worktree_path: &str) -> Result` (the new row's id, from `last_insert_rowid()` read in the same `database.call` closure as the `INSERT OR REPLACE`); `Repositories::worktree_creation_record_id(&self, worktree_path: &str) -> Result>`; the struct field `ResolvedRemovalThread.creation_record_id: Option` replaces `creation_record: String`. `delete_worktree_creation_record` is removed, with its repository test and its name in the list in `apps/server/tests/repositories.rs`; `worktree_creation_record_id` is added to that list. +- Interfaces: consumes `Repositories::reap_worktree_creation_record(&self, record_id: i64) -> Result<()>`, `Repositories::list_worktree_creation_records`. Produces: `Repositories::record_worktree_creation(&self, project_cwd: &str, worktree_path: &str) -> Result` (the new row's id, from `last_insert_rowid()` read in the same `database.call` closure as the `INSERT OR REPLACE`); `Repositories::worktree_creation_record_id(&self, worktree_path: &str) -> Result>`; the struct field `ResolvedRemovalThread.creation_record_id: Option` replaces `creation_record: String`. `delete_worktree_creation_record` is removed, with its repository test `worktree_creation_record_delete_removes_only_that_path` and its name in the method list in `apps/server/tests/repositories.rs`; `worktree_creation_record_id` is added to that list. +- Every caller and writer, searched with `rg`: + - `delete_worktree_creation_record`: `create_managed_worktree_locked` (dispatch-failure arm), `remove_worktree_owned` (after `persist_detach`), the repository test above, and the list in `tests/repositories.rs`. All four are covered by this task. + - `record_worktree_creation`: `create_managed_worktree_locked`, `orchestration_effects.rs` (bootstrap worktree; it ignores the success value, so it compiles unchanged), the repository tests, the `worktree_audit.rs` tests, the `worktree_catalog_rpc.rs` test that records `{main}/`, and `Fixture::record_creation` in `tests/profiler_worktree_audit.rs` (it ignores the value). No other writer exists. Run `rg -n "record_worktree_creation|delete_worktree_creation_record|creation_record\b" apps/server` before and after. + - `ResolvedRemovalThread.creation_record`: set in `resolve_removal_thread`, read in `remove_worktree_owned`. - Skills: `tdd` (failing test before the signature change) -- Read first: `apps/server/src/persistence/repositories.rs` tests `worktree_creation_record_delete_removes_only_that_path` and the reap-by-id test just above it (the model for the id test) +- Read first: `apps/server/src/persistence/repositories.rs` tests `worktree_creation_record_delete_removes_only_that_path` and the reap-by-id test just above it (the model for the id test); `RemovalFixture` and the sibling test `removal_waits_for_admitted_work_before_checking_the_session` in `worktree_catalog_rpc.rs` (the held-admission pattern) - Tier: most capable - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib creation_record` and `cargo test -p bibcode-server --test repositories` exit 0, and `rg -n "delete_worktree_creation_record" apps/server` finds nothing. +- Done when: `cargo test -p bibcode-server --lib -- worktree_creation_record_id_names_the_current_record removal_keeps_a_record_created_while_it_drains` and `cargo test -p bibcode-server --test repositories` exit 0, and `rg -n "delete_worktree_creation_record" apps/server` finds nothing. Steps 1. [ ] Write the failing repository test `worktree_creation_record_id_names_the_current_record` in `repositories.rs`. Record `/worktrees/feature-a`; assert `worktree_creation_record_id` returns `Some(id)` equal to the value `record_worktree_creation` returned. Record the same path again; assert the new id differs. Reap the old id; assert the later record remains. -2. [ ] Write the failing handler test `removal_keeps_a_record_created_while_it_drains` next to `worktree_creation_record_follows_create_and_remove`. Use `RemovalFixture` with no live session and `removal_drain_entered` set as in task 3.1. Call `record_worktree_creation` for the fixture's main checkout and `fixture.linked` path first. Hold an admission with `registry.acquire_admission("removed-thread", [fixture.linked.as_path()])`, spawn the removal, wait on `entered`, then call `record_worktree_creation` for the same path again and remember the new id. Drop the admission, join the removal, and assert it succeeded and `list_worktree_creation_records` still holds the new `record_id`. This is the deterministic gap. -3. [ ] Run `cargo test -p bibcode-server --lib creation_record` and `cargo test -p bibcode-server --test repositories`. Expected: FAIL to compile or fail on the new signature and, for the handler test, FAIL because the path delete removes the new record. +2. [ ] Write the failing handler test `removal_keeps_a_record_created_while_it_drains` next to `worktree_creation_record_follows_create_and_remove`. It uses the real handler and real SQLite (no stubs); time is real, no paused clock. Use `RemovalFixture` with no live session and `removal_drain_entered` set as in task 3.1. Call `record_worktree_creation` for the fixture's main checkout and `fixture.linked` path first. Hold an admission with `registry.acquire_admission("removed-thread", [fixture.linked.as_path()])`, spawn the removal, wait on `entered` (30 s guard), then call `record_worktree_creation` for the same path again and remember the new id. Drop the admission, join the removal (30 s guard, so the old removal has settled before asserting), and assert it succeeded and `list_worktree_creation_records` holds exactly one record whose `record_id` is the new id. This is the deterministic gap. +3. [ ] Run `cargo test -p bibcode-server --lib -- worktree_creation_record_id_names_the_current_record removal_keeps_a_record_created_while_it_drains` and `cargo test -p bibcode-server --test repositories`. Expected: FAIL to compile on the new signature (the handler test also proves the old by-path delete is wrong once compiled). 4. [ ] Implement the repository changes above. In `resolve_removal_thread`, call `worktree_creation_record_id(&persisted_path.to_string_lossy())` and store the result in `creation_record_id`. In `remove_worktree_owned`, replace the delete with `reap_worktree_creation_record(id)` when `Some(id)`, keeping the `tracing::warn!` text. -5. [ ] In `create_managed_worktree_locked`, keep the id that `record_worktree_creation` now returns (`let record_id = ...?`-style, keeping the existing error arm). In the dispatch-failure arm, replace `delete_worktree_creation_record(&created.worktree.path)` with `reap_worktree_creation_record(record_id)`. Confirm `orchestration_effects.rs` still compiles (`if ... && let Err(error) = ...await` ignores the new `i64`); change it only if the compiler requires. +5. [ ] In `create_managed_worktree_locked`, keep the id that `record_worktree_creation` now returns. In the dispatch-failure arm, replace `delete_worktree_creation_record(&created.worktree.path)` with `reap_worktree_creation_record(record_id)`. Confirm `orchestration_effects.rs` still compiles; change it only if the compiler requires. 6. [ ] Run the commands from step 3. Expected: PASS. -7. [ ] Prove the test can fail: temporarily change the SQL in `reap_worktree_creation_record` to `DELETE FROM worktree_creation_records WHERE worktree_path = (SELECT worktree_path FROM worktree_creation_records WHERE record_id = ?)` (a by-path delete in disguise), then run `cargo test -p bibcode-server --lib removal_keeps_a_record_created_while_it_drains`. Expected: FAIL. Restore the SQL and run it again. Expected: PASS. +7. [ ] Prove the test can fail. The SQL cannot be the mutation: `INSERT OR REPLACE` has already removed the old row, so a by-path rewrite of the reap SQL would delete nothing. Instead, by hand, revert the removal caller in `remove_worktree_owned` to deleting by the path it captured (add a private `delete_worktree_creation_record` back, or a path-keyed `DELETE` through `database().call`, and call it with `resolved.path`'s stored spelling). Run `cargo test -p bibcode-server --lib -- removal_keeps_a_record_created_while_it_drains`. Expected: FAIL, the replacement record is gone. Restore the id-based call and run it again. Expected: PASS. 8. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. +The dispatch-failure arm is by id for the same reason. Its end state (no record left) is asserted by the test in task 3.3; a recreation race inside that arm is not reproducible without a hook and is covered by the id lookup test in step 1. + ### Task 3.3: Audit both creation-rollback arms -- Satisfies: R32. Scenario: a creation whose record write fails, and one whose dispatch fails, each emit one `worktree.removal` event with mode `create-rollback`. +- Satisfies: R32. Scenario: a creation whose record write fails, and one whose dispatch fails, each emit exactly one `worktree.removal` event with mode `create-rollback`, and removing either production call alone fails a test. - Depends on: 3.2 (same function and arms) -- Files: modify `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/mcp/tools/worktrees.rs` (header comment); test `apps/server/src/production/worktree_catalog_rpc.rs` and `apps/server/tests/profiler_worktree_audit.rs` -- Interfaces: consumes `worktree_audit::record_removal_outcome(mode: &str, outcome: Result<(), &str>, duration: Duration, timed_out_drain: bool, cancelled: bool)`, `GitRepository::rollback_managed_worktree_creation` (its `Err` carries `.detail`). Produces: the mode string `create-rollback` and exactly two reason tokens. A rollback that succeeded records `Ok(())` (event only, `ok: true`, `reason: null`, no finding). A rollback that failed records `Err("internal")` (event with `ok: false`, `reason: "internal"`, plus an Error `worktree.removal-failed` finding). Never pass the rollback detail or a path. Phase 9 documents these exact strings in `events.md`. +- Files: modify `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/mcp/tools/worktrees.rs` (header comment); test `apps/server/src/production/worktree_catalog_rpc.rs` and the existing test `the_audit_and_removal_outcomes_reach_the_profiler` in `apps/server/tests/profiler_worktree_audit.rs` +- Interfaces: consumes `worktree_audit::record_removal_outcome(mode: &str, outcome: Result<(), &str>, duration: Duration, timed_out_drain: bool, cancelled: bool)`, `GitRepository::rollback_managed_worktree_creation` (its `Err` carries `.detail`; its only callers are the two arms below). Produces: the mode string `create-rollback` and exactly two outcomes. A rollback that succeeded records `Ok(())` (event with `ok: true`, `reason: null`, no finding). A rollback that failed records `Err("internal")` (event with `ok: false`, `reason: "internal"`, plus an Error `worktree.removal-failed` finding). Never pass the rollback detail or a path. Phase 13 documents these exact strings in `events.md`. Entry points: `create_managed_worktree_locked` is reached by the `worktree.createManaged` RPC and by the MCP tool `create_worktree` in `mcp/tools/worktrees.rs`, which calls the same handler; `create_worktree_panel` creates no worktree and has no rollback. The two arms are the only audit sites. - Skills: `tdd` (events asserted before the calls exist), `bibcode-profiler` (event shape and coverage rules) -- Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` functions `record_removal_outcome` and `removal_failure` (which reasons raise findings); `apps/server/tests/profiler_worktree_audit.rs` (how events are read: `shutdown_and_read_events`, the `worktree.removal` filter) +- Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` functions `record_removal_outcome` and `removal_failure` (which reasons raise findings); `apps/server/tests/profiler_worktree_audit.rs` (the one test, its `Fixture`, and the final `removals` assertions) - Tier: standard - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib create_rollback` and `cargo test -p bibcode-server --test profiler_worktree_audit` exit 0. +- Done when: `cargo test -p bibcode-server --lib -- create_rollback` and `cargo test -p bibcode-server --test profiler_worktree_audit` exit 0. -Settled before writing: no existing test forces the dispatch-failure arm. The record-write arm is already forced by `a_creation_whose_record_cannot_be_written_fails_and_leaves_no_worktree` (it drops the `worktree_creation_records` table). Inject the dispatch failure like this: first create a thread with id `dup-thread` through `fixture.engine.dispatch(OrchestrationCommand::ThreadCreate { ... })` (copy the command from `create_thread` in `RemovalFixture`), then call `create_managed(&fixture, "dup", "feature/dup")` after `git branch feature/dup`. `managed_create_payload` builds the thread id as `"{command_id}-thread"`, so use command id `dup-thread`'s prefix: create the thread `dup-thread` and the command id `dup`. The engine refuses with "Thread 'dup-thread' already exists", which takes the dispatch-failure arm. +Settled before writing: +- No existing test forces the dispatch-failure arm. The record-write arm is forced by `a_creation_whose_record_cannot_be_written_fails_and_leaves_no_worktree` (it drops the `worktree_creation_records` table). +- The profiler is process-global, so `tests/profiler_worktree_audit.rs` keeps all scenarios in the one test `the_audit_and_removal_outcomes_reach_the_profiler`. Do not add a second test there. Extend it. +- The harness is real, not stubbed: `Fixture::new` builds `WorktreeCatalogRpcServices::new(catalog, engine)`, which takes its creation Git from `catalog.git_repository()` (a real `GitRepository::default()`), and `register_worktree_catalog_rpc` registers `worktree.createManaged`. So the test can send that RPC over the existing socket. +- The existing assertion `removals.len() == 4` counts every `worktree.removal` event. It becomes 6 in this task, and its message names the two rollbacks. Steps -1. [ ] Add a pure helper test target: in `worktree_catalog_rpc.rs` declare `fn create_rollback_outcome(rolled_back: bool) -> Result<(), &'static str>` returning `Ok(())` for true and `Err("internal")` for false. Write the failing lib test `create_rollback_outcome_uses_fixed_tokens` asserting both values. -2. [ ] Write the failing handler test `a_dispatch_failure_rolls_back_the_worktree_and_its_record`: with the injection above, assert `create_managed` returns the error, the worktree folder is gone, `creation_records(&fixture)` is empty, and the `git worktree list` inventory holds only the primary checkout (copy the assertions of the record-write failure test). -3. [ ] Write the failing integration test `a_creation_rollback_is_audited` in `tests/profiler_worktree_audit.rs`, using the same harness and `request`/`success_value` helpers as the removal test there. Send `worktree.createManaged` with a thread id that already exists, shut down, read events, and assert exactly one `worktree.removal` event with `mode == "create-rollback"`, `ok == true`, `reason == null`, and no `worktree.removal-failed` finding for it. If the harness cannot drive `worktree.createManaged`, assert the same through the lib test with the profiler enabled the way existing lib tests in `worktree_audit.rs` enable it, and state that in the final report. -4. [ ] Run `cargo test -p bibcode-server --lib create_rollback` and `cargo test -p bibcode-server --test profiler_worktree_audit`. Expected: FAIL (helper missing, no event recorded). +1. [ ] In `worktree_catalog_rpc.rs` write the failing lib test `create_rollback_outcome_uses_fixed_tokens` for a new `fn create_rollback_outcome(rolled_back: bool) -> Result<(), &'static str>`: `true` gives `Ok(())`, `false` gives `Err("internal")`. This pins the failed-rollback token; the integration test cannot reach a failing rollback. +2. [ ] In the same module write the failing handler test `create_rollback_after_a_dispatch_failure_leaves_no_worktree_or_record`. Injection: `git branch feature/dup` (use the module's `git(&fixture.main, ...)`), dispatch a `ThreadCreate` for thread id `dup-thread` through `fixture.engine` (copy the command from `create_thread` in `RemovalFixture`), then call `create_managed(&fixture, "dup", "feature/dup")`. `managed_create_payload` builds the thread id `dup-thread`, so the engine refuses with "Thread 'dup-thread' already exists" and the dispatch arm runs. Assert the call returns an error, the managed folder is gone, `creation_records(&fixture)` is empty, and Git lists only the primary checkout (copy the last assertions of the record-write failure test). +3. [ ] Extend `the_audit_and_removal_outcomes_reach_the_profiler`, after its last `scan_once` and before `shutdown_and_read_events`, with both arms, dispatch arm first: Expected: the change is in place, and the next command step checks it. + - Dispatch arm: run `git branch feature/dup-a` with the file's `git(&fixture.git_config, &fixture.main, ...)`; dispatch a `thread.create` through `fixture.engine` using `serde_json::from_value(json!({"type":"thread.create","commandId":"dup-a-create","threadId":"dup-a-thread","projectId":"project-1","title":"Existing","modelSelection":{"instanceId":"codex","model":"gpt-5"},"runtimeMode":"full-access","createdAt":"2026-10-09T00:00:01Z"}))` (the shape `engine.rs` tests use); then `request(fixture.socket(), "800", "worktree.createManaged", ...)` with `commandId` `dup-a`, `threadId` `dup-a-thread`, `refName` `feature/dup-a`, `newRefName` and `baseRefName` null and the same `threadDefaults` as `adopt`. Await `failure_reason(fixture.socket(), "800")` and assert it is `orchestration-failed`. Assert `fixture.creation_records().await` has not gained a path (compare with the list taken just before). + - Record-write arm, last, because it breaks later scans: `git branch feature/dup-b`; run `fixture.repositories.database().call(|connection| { connection.execute_batch("DROP TABLE worktree_creation_records")?; Ok(()) }).await`; send `worktree.createManaged` as request `801` with command `dup-b`, thread `dup-b-thread`, ref `feature/dup-b`; assert `failure_reason` is `internal`. + - After the events are read, extend the final assertions: `removals.len()` is 6; the filtered list for `mode == "create-rollback"` has exactly 2 entries, both `(ok true, reason None, drainTimedOut false, cancelled false)`. This one count fails if either production call is removed alone (it drops to 1), so each call has a test that fails without it. Also assert no `worktree.removal-failed` event cites a `create-rollback` event, since both rollbacks succeed. +4. [ ] Run `cargo test -p bibcode-server --lib -- create_rollback` and `cargo test -p bibcode-server --test profiler_worktree_audit`. Expected: FAIL (helper missing; the integration count is 4, not 6). 5. [ ] Implement the helper. In both rollback arms of `create_managed_worktree_locked`, after `rollback_managed_worktree_creation` returns and before the error is built, call `record_removal_outcome("create-rollback", create_rollback_outcome(rollback.is_ok()), started.elapsed(), false, cancellation.is_cancelled())` with `started = Instant::now()` taken before the rollback. Update the header comment of `mcp/tools/worktrees.rs` to say a failed creation's rollback records a `create-rollback` outcome too. 6. [ ] Run the commands from step 4. Expected: PASS. -7. [ ] Prove the test can fail: comment out the call in the dispatch arm, run `cargo test -p bibcode-server --test profiler_worktree_audit a_creation_rollback_is_audited`. Expected: FAIL. Restore it and run again. Expected: PASS. +7. [ ] Prove the test can fail, once per call. Revert this task's change by hand in the dispatch arm only (delete its `record_removal_outcome` call) and run `cargo test -p bibcode-server --test profiler_worktree_audit`. Expected: FAIL (2 create-rollback events expected, 1 found). Restore it, then delete the call in the record-write arm only and run again. Expected: FAIL for the same reason. Restore it and run again. Expected: PASS. 8. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 3.4: Reap a record only when its parent directory exists @@ -95,17 +108,17 @@ Steps - Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` `resolve`, `reap_gone_records`, and the tests `gone_after_read` and `recorded_paths` (the seam and the helpers to copy) - Tier: standard - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib worktree_audit` exits 0. +- Done when: `cargo test -p bibcode-server --lib -- a_record_under_a_missing_parent_is_kept a_record_whose_folder_is_missing_under_an_existing_parent_is_reaped a_record_is_kept_when_its_parent_vanishes_before_the_reap` exits 0. Steps 1. [ ] Write the failing test `a_record_under_a_missing_parent_is_kept`: record `/gone/wt` where `/gone` does not exist, call `gone_after_read`, assert it is empty. In the same test call `reap_gone_records` with that record and assert `recorded_paths` still lists it. 2. [ ] Write the failing test `a_record_whose_folder_is_missing_under_an_existing_parent_is_reaped`: record `/wt` with `` present and `wt` absent; assert `gone_after_read` holds it and `reap_gone_records` deletes it. 3. [ ] Write the failing test `a_record_is_kept_when_its_parent_vanishes_before_the_reap`: take the gone list for a record with an existing parent, remove the parent directory, then call `reap_gone_records`; assert the record remains (the re-check). -4. [ ] Run `cargo test -p bibcode-server --lib worktree_audit`. Expected: FAIL on tests 1 and 3 (the old code reaps on `NotFound` alone); test 2 passes. +4. [ ] Run `cargo test -p bibcode-server --lib -- a_record_under_a_missing_parent_is_kept a_record_whose_folder_is_missing_under_an_existing_parent_is_reaped a_record_is_kept_when_its_parent_vanishes_before_the_reap`. Expected: FAIL on tests 1 and 3 (the old code reaps on `NotFound` alone); test 2 passes. 5. [ ] Implement `record_folder_is_gone` and call it from both places. 6. [ ] Run the command from step 4. Expected: PASS. -7. [ ] Prove the test can fail: make `record_folder_is_gone` ignore the parent check, run the command. Expected: tests 1 and 3 FAIL. Restore and run again. Expected: PASS. +7. [ ] Prove the tests can fail: revert this task's change by hand (make `record_folder_is_gone` return true on `NotFound` alone, the old rule) and rerun the step 4 command. Expected: the tests `a_record_under_a_missing_parent_is_kept` and `a_record_is_kept_when_its_parent_vanishes_before_the_reap` FAIL. Restore it and rerun. Expected: PASS. 8. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 3.5: Record the limits in comments diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md index 479e2f32a..28608caa4 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md @@ -3,7 +3,7 @@ - Goal: A remote update install keeps Files edits held across a reload, a lost ended event and a source registered mid-install, the installing dialog always has a way out, and the desktop event names are tied by a test, each with a failing-first test. - Round: 1 - Depends on: none -- Owns: `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/files/unsavedFileEdits.test.ts`, `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`, `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` and its test (only if Task 4.4 shows the action is unreachable), new `scripts/desktop-event-names.test.ts` +- Owns: `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/files/unsavedFileEdits.test.ts`, `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`, `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` and its test (only if Task 4.4 shows the action is unreachable), new `scripts/desktop-event-names.test.ts`; `apps/web/src/routes/__root.tsx` and `apps/web/src/routes/__root.test.tsx` (only in Task 4.4's defect branch) - Must not touch: `apps/web/src/tauriDesktopBridge.ts` and `apps/desktop/src-tauri/**` (read only), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ui/**` - Skills: `tdd` (every task is a behavior change with a failing test first), `vercel-react-best-practices` (the dialog and host are React), `better-interface` (new button and text need the UI.md review) - Read first: `AGENTS.md` (completion rules, sensitive data); `UI.md` (a dialog needs a way out, disabled states explain themselves); `docs/architecture/overview.md` section on the desktop close protocol and `desktop:update-install-ended` (current contract); `docs/plans/2026-11-10-desktop-save-and-close-design.md` (R62 and the event table) @@ -31,33 +31,36 @@ Steps 2. [ ] Run `cd apps/web && vp test run --project unit src/components/files/unsavedFileEdits.test.ts -t "registered during an update install"`. Expected: FAIL with `holdSavingForLeave` called 0 times. 3. [ ] In `unsavedFileEdits.ts` change `blockForUpdateInstall` to keep its holds in a mutable array on `updateInstall`, and make `registerUnsavedSource` push a hold onto it while `updateInstall !== null`. 4. [ ] Run the same command. Expected: PASS. -5. [ ] Prove the test can fail: remove the push in `registerUnsavedSource`, run the command. Expected: FAIL. Restore it and run again. Expected: PASS. +5. [ ] Prove the test can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 2 command. Expected: FAIL. Restore it and rerun. Expected: PASS. 6. [ ] Run `vp check apps/web/src/components/files/unsavedFileEdits.ts apps/web/src/components/files/unsavedFileEdits.test.ts`. Expected: no errors. ### Task 4.2: Restore the block when the window loads during an install -- Satisfies: R35. Given a window that reloads while the desktop reports `requestedBy` set and `phase` `protecting` or `installing`, Then edits are blocked again and the installing dialog shows. A desktop without `getUpdateState` counts as "not installing". A query that fails (rejects, or gives no answer within 5 seconds) is retried and never releases a block. +- Satisfies: R35. Given a reload during an install this window allowed, Then saving stays held at once, the installing dialog shows with its "Check again" button, and the first state query rejecting changes nothing. Only a confirmed "no remote install in progress" answer (a query answer, an `onUpdateState` push, or the `onUpdateInstallEnded` event) clears the block. A query that fails (rejects, or gives no answer within 5 seconds) is retried and clears nothing. A fresh window with no marker holds nothing until an answer says a remote install is running. A desktop without `getUpdateState` counts as "not installing". - Depends on: 4.1 - Files: modify `apps/web/src/components/files/unsavedFileEdits.ts`; test `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` -- Interfaces: consumes `DesktopBridge["getUpdateState"]` and `["onUpdateState"]`; changes `respondToDesktopCloseRequests(bridge: Pick): () => void` (the last two optional at runtime); produces the non-exported `isRemoteInstallRunning(state: DesktopUpdateState | null | undefined): boolean` and `askInstallState(bridge, onAnswer: (running: boolean) => void): () => void`, which returns its cancel function. Used again by task 4.3. -- Retry rule: `askInstallState` calls `getUpdateState()` with a 5 second timeout. A rejection or timeout is not an answer: it logs once with `console.warn` and asks again every 2 seconds, without limit. The loop stops on the first confirmed answer, on any state pushed by `onUpdateState` (a push is a confirmed answer), and on cleanup. A confirmed answer that is not `isRemoteInstallRunning` releases a block; a failure never does. -- Before the first answer: a fresh load or reload holds nothing. The window cannot know, a reload has lost the block, and holding every save on a guess would stall editing on every start. If the answer is "running", `blockForUpdateInstall()` holds all sources, including ones registered meanwhile (4.1). This differs from a known block (the module already has `updateInstall`): there a failed query changes nothing, so the block stays. +- Interfaces: consumes `DesktopBridge["getUpdateState"]` and `["onUpdateState"]`; changes `respondToDesktopCloseRequests(bridge: Pick): () => void` (the last two optional at runtime); produces the non-exported `isRemoteInstallRunning(state: DesktopUpdateState | null | undefined): boolean`, `askInstallState(bridge, onAnswer: (running: boolean) => void): () => void` (returns its cancel function), and the marker helpers `readInstallMarker(): boolean` and `writeInstallMarker(on: boolean): void`, used again by Task 4.3. +- Marker: key `bibcode:update-install-allowed`, value `"1"`, kept in `window.sessionStorage` (it survives a reload of the same window and ends with the window). `rg sessionStorage` finds no per-window persisted-flag helper outside tests, so these two helpers are new and live in `unsavedFileEdits.ts`; every read and write is wrapped in `try`/`catch` and a failure means "no marker". Writers: `blockForUpdateInstall()` writes it (the one place a block starts: the allowed `update` reply, the load-time restore and a confirmed running answer). Clearers: `endUpdateInstall()` only, which every confirmed end already reaches (the ended event, a pushed state, a confirmed query answer, the cancelled late answer). Nothing else removes it, and no failure path does. +- Rule on load: a present marker calls `blockForUpdateInstall()` at once, before any query, so the installing dialog (with "Check again") shows and no save runs silently. With no marker, nothing is held until an answer says `isRemoteInstallRunning`. If the desktop has no `getUpdateState`, a present marker cannot be confirmed either way, so it is cleared and nothing is held. +- Retry rule: `askInstallState` calls `getUpdateState()` with a 5 second timeout. A rejection or timeout is not an answer: it logs once with `console.warn` and asks again every 2 seconds, without limit. The loop stops on the first confirmed answer, on any state pushed by `onUpdateState`, and on cleanup. +- Limit stated plainly: a unit test cannot prove the marker survives a relaunched web process. The tests below reload by resetting the module registry (`vi.resetModules()` and importing `unsavedFileEdits` and the dialog again) with the same `sessionStorage`. The proof that a real reload keeps the marker is a packaged check in Phase 8: reload the window during a mocked remote install and assert the DOM state `[role="alertdialog"]` containing the text "Installing an update" and a button named "Check again" while the marker `sessionStorage["bibcode:update-install-allowed"] === "1"`. - Skills: tdd (red-green cycle) -- Read first: `apps/web/src/components/files/unsavedFileEdits.ts` (`respondToDesktopCloseRequests`); `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` (describe "a remote update install the window allowed") +- Read first: `apps/web/src/components/files/unsavedFileEdits.ts` (`respondToDesktopCloseRequests`, `blockForUpdateInstall`, `endUpdateInstall`; every writer of `updateInstall`); `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` (describe "a remote update install the window allowed": its fake bridge and `afterTheSaveDelay`; the file uses the real modules, no mock replaces `unsavedFileEdits`) - Tier: most capable - TDD: red-green - Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx` exits 0 with the new tests passed Steps -1. [ ] In the describe "a remote update install the window allowed", extend the fake bridge so `getUpdateState` and `onUpdateState` can be set per test; use fake timers for the retry tests. Write the failing test `a window that loads during an install blocks saving and shows the installing dialog`: state `{ requestedBy: { label: "Test User", detail: null }, phase: "installing" }`, an unsaved session, mount the host, assert the dialog text contains "Installing an update" and that no write happens after the autosave delay (`afterTheSaveDelay`). -2. [ ] Write the failing test `a reload during an install whose first query rejects keeps asking and then ends the block`: first call rejects; assert nothing is blocked yet and the query is asked again after 2 seconds; the second call answers running, so the dialog shows and saving is held; a third (via the next recheck or a pushed state) answers `{ requestedBy: null, phase: "idle" }` and the block ends and saving resumes. Add `a failed query never releases a known block` (block started through the close protocol, `getUpdateState` rejects or hangs past 5 seconds: the dialog stays and the query repeats) and `a pushed update state ends the wait`. -3. [ ] Write `a desktop without getUpdateState does not block` (member absent: no dialog, `session.flush()` saves, no timer left running). -4. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "loads during an install|first query rejects|known block|pushed update state ends|without getUpdateState"`. Expected: FAIL (no dialog, no retry); the absent-member test passes already. -5. [ ] Implement `isRemoteInstallRunning` and `askInstallState` and call it from `respondToDesktopCloseRequests` on subscribe; cancel it in the returned cleanup. -6. [ ] Run the same command. Expected: PASS. -7. [ ] Prove the tests can fail: make a rejection count as "not running", run. Expected: the rejects and known-block tests FAIL. Restore and run. Expected: PASS. -8. [ ] Run `vp check` on the two files. Expected: no errors. +1. [ ] In the describe "a remote update install the window allowed", extend the fake bridge so `getUpdateState` and `onUpdateState` can be set per test. Time is controlled with `vi.useFakeTimers()` in the new tests (restore real timers in `afterEach`); replace `afterTheSaveDelay` in them with `await vi.advanceTimersByTimeAsync(700)`; after advancing time past a rejected query, `await` the pending promise (`await vi.advanceTimersByTimeAsync(0)`) before asserting. +2. [ ] Write the failing test `reload during an install: the marker restores the block while the first query rejects`: allow an install through the close protocol (marker written), reset modules and remount the host, `getUpdateState` rejects; assert the dialog text contains "Installing an update", a button "Check again" exists, the session's `persist` is not called after advancing 700 ms, and the query is asked again after 2 seconds. +3. [ ] Write the failing test `reload during an install: a confirmed not-running answer clears the block and the marker`: continue from step 2, the retry answers `{ requestedBy: null, phase: "idle" }`; assert the dialog is gone, `sessionStorage.getItem("bibcode:update-install-allowed")` is null and saving resumes. +4. [ ] Write the failing test `a fresh window with no marker holds nothing until an answer says an install runs`: no marker, query pending; assert no dialog and `session.flush()` saves; then answer `{ requestedBy: { label: "Test User", detail: null }, phase: "installing" }`; assert the dialog shows and the marker is written. Add `a failed query never releases a known block` (timeout past 5 seconds and rejection: dialog and marker stay), `a pushed update state ends the wait`, `a desktop without getUpdateState clears a stale marker and holds nothing`, and `an unusable sessionStorage means no marker and no crash` (make `getItem` and `setItem` throw). +5. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "reload during an install|fresh window|failed query never|pushed update state ends|without getUpdateState|unusable sessionStorage"`. Expected: FAIL (no marker, no dialog, no retry). Every selector is a substring of a title written here. +6. [ ] Implement the marker helpers, `isRemoteInstallRunning` and `askInstallState`; write the marker in `blockForUpdateInstall`, clear it in `endUpdateInstall`, restore on load as the rules above say, and cancel the loop in the cleanup `respondToDesktopCloseRequests` returns. +7. [ ] Run the step 5 command. Expected: PASS. +8. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 5 command. Expected: FAIL. Restore it and rerun. Expected: PASS. +9. [ ] Run `cd apps/web && vp check src/components/files/unsavedFileEdits.ts src/components/files/UnsavedChangesCloseDialog.test.tsx`. Expected: no errors. ### Task 4.3: Give the installing dialog a way out @@ -74,21 +77,21 @@ Steps Steps 1. [ ] Write the failing test `a pushed update state with no remote install closes the installing dialog` (fake bridge captures the `onUpdateState` listener; after allow, push `{ requestedBy: null, phase: "idle" }`; assert the dialog is gone, saving resumes, and the "The update did not install" toast was added). -2. [ ] Write the failing test `Check again asks the desktop for the state and closes the dialog when no install runs`: dialog shows a button named "Check again"; `getUpdateState` resolves a not-running state; click; assert the dialog closes. Also `Check again with an install still running keeps the dialog`, and `a rejecting state query keeps the dialog, the block and the button, and the 4.2 retry keeps asking`. +2. [ ] Write the failing test `Check again asks the desktop for the state and closes the dialog when no install runs`: dialog shows a button named "Check again"; `getUpdateState` resolves a not-running state; click; assert the dialog closes. Also `Check again with an install still running keeps the dialog`, and `a rejecting state query keeps the dialog, the block, the marker and the button, and the 4.2 retry keeps asking`. 3. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "pushed update state|Check again|rejecting state"`. Expected: FAIL (no such button, dialog stays). 4. [ ] Implement the `onUpdateState` subscription and `recheckUpdateInstall`, and add the "Check again" `Button variant="outline"` in a footer of `UpdateInstallingDialog` with a disabled state while the query runs. The description keeps its current text and gains one sentence: "If it seems stuck, check again." UI.md review: confirm the dialog now has a visible exit, the button is not the primary action, and Escape still does nothing while an install may restart the app. 5. [ ] Run the same command. Expected: PASS. -6. [ ] Prove the tests can fail: remove the `endUpdateInstall` call in `recheckUpdateInstall`, run. Expected: FAIL. Restore and run. Expected: PASS. +6. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 3 command. Expected: FAIL. Restore it and rerun. Expected: PASS. 7. [ ] Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the React and UI.md review outcome in the final report. ### Task 4.4: Decide whether the dialog hides the Manage devices toast - Satisfies: R39 (decide by test). Given the installing dialog open over the remote-update toast, Then either the toast action is reachable (the test stays as proof) or the action is made reachable from the dialog. - Depends on: 4.3 -- Files: modify `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`; only on a defect, modify `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx` and `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; test `apps/web/src/components/RemoteUpdateRequestNotifier.test.tsx` on a defect -- Interfaces: consumes `RemoteUpdateRequestNotifier({ onManageDevices })` and the toast manager from `~/components/ui/toast`; on a defect produces an optional `onManageDevices?: () => void` prop on `UnsavedChangesCloseHost`, shown as a secondary button "Manage devices" in the installing dialog +- Files: modify `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`; only on a defect, modify `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/routes/__root.tsx` (the four `UnsavedChangesCloseHost` mounts) and `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` if its action must change; test `apps/web/src/routes/__root.test.tsx` on a defect +- Interfaces: consumes `RemoteUpdateRequestNotifier({ onManageDevices })` and the toast manager from `~/components/ui/toast`; on a defect produces an optional `onManageDevices?: () => void` prop on `UnsavedChangesCloseHost`, shown as a secondary button "Manage devices" in the installing dialog and absent when the prop is absent - Skills: mstack-diagnose (the result of a DOM probe picks the branch); better-interface (any new action needs the UI.md review) -- Read first: `apps/web/src/components/ui/toast.tsx` (`Toast.Portal`, `Toast.Viewport`); `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; `apps/web/src/AppRoot.tsx` (where the notifier and host mount) +- Read first: `apps/web/src/components/ui/toast.tsx` (`Toast.Portal`, `Toast.Viewport`); `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; `apps/web/src/AppRoot.tsx` (mounts the notifier only, with a `router.navigate({ to: "/settings/remote-servers", search: { tab: "share" } })` callback); `apps/web/src/routes/__root.tsx` (mounts `UnsavedChangesCloseHost` at four sites: the `/pair` route, the unauthenticated gate states, the authenticated layout, and `RootRouteErrorView`); `apps/web/src/routes/__root.test.tsx` (existing route tests) - Tier: most capable - TDD: exempt (the test is the deliverable; code changes only on a defect, and then red-green) - Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast"` exits 0 and the outcome line is recorded in the final report @@ -98,7 +101,7 @@ Steps 1. [ ] Write the DOM test `the Manage devices toast action is reachable while the installing dialog is open`: mount the real toast viewport (not the mocked manager), add a toast with an action as the notifier does, open the installing dialog, then assert whether the action element has an `[inert]` or `[aria-hidden="true"]` ancestor and whether `document.elementFromPoint` or a keyboard Tab can reach it. 2. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast"`. Expected: either PASS (no defect) or FAIL on the inert assertion (defect). 3. [ ] Result "no defect" (PASS): keep the test as proof, add no code, and write "R39: no defect, toast action reachable over the dialog" in the final report. -4. [ ] Result "defect" (FAIL): add the "Manage devices" secondary button to `UpdateInstallingDialog` through an `onManageDevices` prop passed from where `AppRoot.tsx` mounts the host; add a test in the dialog test file that the click calls it; run `vp test run --project unit` on both test files. Expected: PASS. Run the UI.md review on the new action. +4. [ ] Result "defect" (FAIL): add the "Manage devices" secondary button to `UpdateInstallingDialog` through an `onManageDevices` prop. The host is mounted by `routes/__root.tsx`, not `AppRoot.tsx`: pass the prop only at the authenticated-layout mount, using the router's navigate to `/settings/remote-servers` with `search: { tab: "share" }`; the `/pair`, gate-state and error-view mounts pass none, so the button is absent there (those states have no Settings to open). Add tests: in the dialog test file that the click calls the prop and that no button shows without it; in `routes/__root.test.tsx` that the authenticated layout passes a working callback and the other three mounts pass none (read that file first and follow how it renders the layout). Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx src/routes/__root.test.tsx`. Expected: PASS. Revert the change by hand and rerun. Expected: FAIL; restore. Run the UI.md review on the new action. 5. [ ] Run `vp check` on changed files. Expected: no errors. ### Task 4.5: Fail when a desktop event name exists on one side only @@ -116,6 +119,6 @@ Steps Steps 1. [ ] Write `every desktop event name exists on both sides of the bridge`: collect the literal sets, assert both are non-empty, and assert set equality, with a failure message that lists the names missing on each side. Allow an explicit `WEB_ONLY` or `RUST_ONLY` list, empty unless a name is emitted by a plugin; justify any entry in a comment. -2. [ ] Run `vp test run scripts/desktop-event-names.test.ts`. Expected: PASS on current code. If it fails, the failure message names a real mismatch: report it, do not edit the bridge or Rust files (not owned by this phase). -3. [ ] Prove the test can fail: temporarily change one string in a scratch copy of the collected set inside the test (for example rename `desktop:update-install-ended` in the in-memory text), run. Expected: FAIL naming the event. Remove the break and run again. Expected: PASS. +2. [ ] Run `vp test run scripts/desktop-event-names.test.ts` from the repository root. Expected: PASS on current code. If it fails, the failure message names a real mismatch: report it, do not edit the bridge or Rust files (not owned by this phase). +3. [ ] Prove the test can fail: in the test only, make the collector read the Rust text with `desktop:update-install-ended` replaced by `desktop:update-install-finished`, run `vp test run scripts/desktop-event-names.test.ts`. Expected: FAIL naming the event on each side. Undo the edit by hand (git stash is not allowed) and rerun. Expected: PASS. 4. [ ] Run `vp check scripts/desktop-event-names.test.ts`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md index 4c7dde2cc..9bcf91f70 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md @@ -3,11 +3,11 @@ - Goal: Expanding or collapsing a reasoning row that the reader brought into view keeps its header within 2 px (R12, diagnosed first, fixed at the first wrong decision), and the Host dashboard values view no longer re-renders on the clock tick alone (R18), each with its tests. - Round: 1 - Depends on: none -- Owns: `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/ChatView.test.tsx`, `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx`, `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (only if the diagnosis lands there), `apps/web/src/components/chat/MessagesTimeline.tsx` (only the `onReasoningRowToggle` call in `ReasoningWorkEntryRow`, only if the diagnosis needs the row id), `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` -- Must not touch: `apps/desktop/e2e/**` (Phase 7 owns the harness; the packaged spec is run, never edited), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ChatMarkdown.tsx`, `apps/web/src/components/hostDashboard/hostDashboard.logic.ts` -- Skills: `vercel-react-best-practices` (required review for every React change under apps/web); `tdd` (red-green cycle for each behavior change); `mstack-diagnose` (Task 5.1 is a diagnosis, not a fix) +- Owns: `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/ChatView.test.tsx`, `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx`, `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (only if the diagnosis lands there), `apps/web/src/components/chat/MessagesTimeline.tsx` and `apps/web/src/components/chat/MessagesTimeline.test.tsx` (temporary instrumentation in Task 5.1; the `onReasoningRowToggle` type and call in Task 5.2), `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` +- Must not touch: `apps/desktop/e2e/**` (Phase 7 owns the harness; the packaged spec is run, never edited), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ChatMarkdown.tsx`, `apps/web/src/components/hostDashboard/hostDashboard.logic.ts`, `apps/web/src/components/chat/threadFind/useThreadFind.test.tsx` (its `onReasoningRowToggle: () => {}` stays valid after the type change) +- Skills: `vercel-react-best-practices` (required review for every React change under apps/web); `tdd` (red-green cycle for each behavior change); `mstack-diagnose` (Task 5.1 is a diagnosis on the real app, not a fix) - Read first: `AGENTS.md` (completion rules, profiler coverage line, sensitive data); `UI.md` (the toggle must not move the reader's place); `docs/architecture/overview.md` section on the web client (state owner of timeline scroll); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (R12, R18 and D14 are binding); `apps/desktop/e2e/specs/chat-reasoning-rows.e2e.ts` (the packaged invariant: `expandThoughtAwayFromEnd` and `expandStreamingThoughtAtEnd`) -- Checkpoint: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0, then `vp check ` and `vp run typecheck` exit 0. Later proof, owned by Phase 8 (Tasks 8.1 and 8.3) and not by this phase: the packaged `chat-reasoning-rows` spec in the Linux lab at a 1000x720 viewport (a failure there reproduces the bug without WebView2; it does not by itself name the cause), and in the Windows VM at the viewport where it failed. +- Checkpoint: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0, then `vp check ` and `vp run typecheck` exit 0, and `rg "\[DEBUG-r12\]" apps/web/src` finds nothing. Later proof, owned by Phase 8 and not by this phase: Task 8.3 (the Windows VM) is the proof that the packaged `chat-reasoning-rows` spec passes where it failed; Task 8.1 (the Linux lab) only guards against a Linux regression, because the spec already passes there (see Task 5.1 step 1). ## Background (read before Task 5.1) @@ -15,7 +15,7 @@ Three code paths can scroll the list to its end after a reasoning row toggles. A - H1, the toggle's end reveal: `onReasoningRowToggle`. It runs when `rowToggleNeedsEndReveal({ mode, scrollDeltaToRevealEnd })` is true (mode `anchoring-new-turn` and `scrollDeltaToRevealEnd <= 1` before the toggle) and `liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current`. Two animation frames later it calls `list.scrollToOffset({ offset: scroll + scrollDeltaToRevealEnd, animated: false })`. Two frames is 16 to 50 ms, the window the issue measured. - H2, the grown-turn follow: `onTimelineRowSizeChanged` then `followGrownTurn`. In mode `anchoring-new-turn` a row that grows starts a two-frame wait, then `followTimelineLiveEnd` runs when `scrollDeltaToRevealEnd - (growth - held) <= 1`, which is the same "the end was in view before" condition. -- H3, the list's end maintenance: `MessagesTimeline` passes `maintainScrollAtEnd={resolveTimelineMaintainScrollAtEnd({ anchored, followsEnd })}`; with `followsEnd` true the config has `on.itemLayout: true`, so LegendList scrolls to its end by itself when it judges the list at its end. +- H3, the list's end maintenance: `MessagesTimeline` passes `maintainScrollAtEnd={resolveTimelineMaintainScrollAtEnd({ anchored: !!anchoredEndSpace, followsEnd })}`. The predicate returns `false` whenever `anchored` is true, whatever `followsEnd` is, and otherwise the config with `on.itemLayout: true`. So H3 can only be the cause while no anchored end space is set, and the value that matters is what the component really passes, not `followsEnd`. A scroll that no call site of H1 or H2 issued is LegendList's own. What makes all three possible: the packaged spec scrolls with `scrollIntoView({ block: "center" })`, which is not a reader gesture. `onIsAtEndChange(false)` returns early when `liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current`, so after that scroll the mode stays `anchoring-new-turn` and `timelineFollowsEnd` stays true. @@ -27,67 +27,72 @@ Paths in this plan: the repository keeps `timelineScrollAnchoring.ts` and `Messa - Satisfies: R12 (diagnose-first step 2). Scenario pinned: Given the reader scrolled a reasoning row to the center without a wheel or touch gesture, When the row is toggled, Then no scroll to the end is made within three animation frames. - Depends on: none -- Files: modify `apps/web/src/components/ChatView.test.tsx` -- Interfaces: consumes `ChatView` props captured by `capturedProps>("messagesTimeline")` with the keys `onReasoningRowToggle: () => void`, `onRowSizeChanged: (info: { index: number; previous: number; size: number }) => void`, `onIsAtEndChange: (isAtEnd: boolean) => void`, `onAnchorReady: (id: MessageId, index: number) => void` and `followsEnd: boolean`; consumes the `h.timelineList` mock with `getState`, `getScrollableNode`, `scrollToOffset`, `scrollToIndex`, `scrollToEnd` as in the test "a pointer press on $pressed before a toggle at an anchored turn's end". Produces a `describe("R12 scroll path after a programmatic scroll")` with three tests, one per hypothesis, each exercising its path alone, and a verdict comment naming the confirmed path or paths. Task 5.2 relies on the verdict and on the shared helper `anchoredTurnAfterProgrammaticScroll()` that this task adds in the test file. -- Skills: mstack-diagnose (decide by test; no fix before the verdict) -- Read first: `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end" (the harness to copy); `apps/web/src/components/ChatView.tsx` `onIsAtEndChange`, `onReasoningRowToggle`, `followGrownTurn`, `onTimelineRowSizeChanged`; `apps/web/src/components/chat/timelineScrollAnchoring.ts` `rowToggleNeedsEndReveal` +- Files: modify (temporary, removed before the task closes) `apps/web/src/components/ChatView.tsx` and `apps/web/src/components/chat/MessagesTimeline.tsx`; modify `apps/web/src/components/ChatView.test.tsx` or `apps/web/src/components/chat/MessagesTimeline.test.tsx` (whichever the trace names, see step 8) +- Interfaces: consumes every scroll call site in `ChatView.tsx` (found with `rg "scrollToOffset|scrollToEnd|scrollToIndex" apps/web/src/components/ChatView.tsx`): `scrollToEnd` (the pill, `onClick={() => scrollToEnd(true)}`), the page-key `scrollToOffset`, the anchor-positioning `scrollToOffset` calls, the pending-offset `scrollToOffset`, `onReasoningRowToggle` (H1), `followTimelineLiveEnd` (H2, its `scrollToOffset`, its `recheck` `scrollToOffset` and its `scrollToEnd` in mode `following-end`) and the thread-open `scrollToEnd`; in `MessagesTimeline.tsx` the minimap `scrollToIndex` and the `maintainScrollAtEnd` prop passed to `LegendList`. Produces: a recorded verdict (a comment above the new test) naming the path, and a unit test that reproduces that path through the real toggle. +- Skills: mstack-diagnose (live trace first; no fix before the verdict) +- Read first: `apps/web/src/components/ChatView.tsx` `onIsAtEndChange`, `onReasoningRowToggle`, `followGrownTurn`, `onTimelineRowSizeChanged`, `followTimelineLiveEnd`; `apps/web/src/components/chat/timelineScrollAnchoring.ts` `rowToggleNeedsEndReveal`; `apps/web/src/components/chat/MessagesTimeline.logic.ts` `resolveTimelineMaintainScrollAtEnd` (false whenever anchored); `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end" and its file-level mocks (see step 8); `apps/desktop/e2e/specs/chat-reasoning-rows.e2e.ts` `expandThoughtAwayFromEnd` - Tier: most capable - TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` exits 0 and the verdict comment above the `describe` names which of H1, H2, H3 issued a scroll. +- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx -t "R12 scroll path"` exits 0; the verdict comment names H1, H2, H3 or another path with the trace line that shows it; and `rg "\[DEBUG-r12\]" apps/web/src` finds nothing. Steps -1. [ ] Read the baseline lab result from the plan log (the coordinator runs the packaged `chat-reasoning-rows` spec in the Linux lab on the base commit before round 1, plan decision P3). Use it only to choose which metrics to reproduce: whether `expandThoughtAwayFromEnd` failed there, the header movement it reported, and the viewport and `scrollTop` it recorded. A failure at 1000x720 is a platform-independent reproduction, not proof of the cause. A pass there means the unit metrics below must stay the issue's WebView2 numbers, and is no evidence for or against any hypothesis. If the log has no baseline result yet, continue with the issue's numbers and say so in the verdict comment. -2. [ ] Add the helper `anchoredTurnAfterProgrammaticScroll()` to the test file. It copies the setup of the existing #173 test (seed the thread, render `ChatView`, send, call `onAnchorReady`) with the spec's metrics: `scroll: 0`, `scrollLength: 720`, an anchor row at index 0, the turn's last row ending inside the viewport so `scrollDeltaToRevealEnd` is at most 1 before the toggle, and no `pointerdown` on the list or the toggle. It then calls `onIsAtEndChange(false)` to stand for the programmatic scroll. It returns the timeline props, the `scrollToOffset`, `scrollToIndex` and `scrollToEnd` mocks, and a `growRow(height: number)` function that raises the last row's height by 1014 px, the distance the issue measured. -3. [ ] Write test `H1: the toggle's end reveal makes no scroll after a programmatic scroll` as `it.fails`. Prediction if H1 is the culprit: calling `onReasoningRowToggle()`, then `growRow(1014)`, then advancing three animation frames gives exactly one `scrollToOffset` call with an offset at or above the old end, so the assertion `scrollToOffset` not called fails, which `it.fails` accepts. -4. [ ] Write test `H2: the grown-turn follow makes no scroll after a programmatic scroll` as `it.fails`. It never calls `onReasoningRowToggle`. It calls `onRowSizeChanged({ index, previous, size: previous + 1014 })` after `growRow(1014)` and advances three frames. Prediction if H2 is the culprit: one `scrollToOffset`, `scrollToIndex` or `scrollToEnd` call. -5. [ ] Write test `H3: the list's end maintenance stays off after a programmatic scroll and a toggle` as `it.fails`. It calls `onReasoningRowToggle()` and reads `followsEnd` from the re-captured `messagesTimeline` props. Prediction if H3 is the culprit: `followsEnd` is still `true`, so LegendList's `maintainScrollAtEnd` stays on while the reader's row is away from the end. Assert `followsEnd` is `false`. -6. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` from `apps/web`. Run it once with each `it.fails` temporarily changed to `it`. Expected: FAIL for the tests of the confirmed hypotheses, that is, they FAIL with the assertion messages above, and the others PASS. A test that passes as `it` is a path that does not scroll; keep it as a plain `it`. -7. [ ] Write the verdict comment above the `describe`: which of H1, H2, H3 failed, and the first wrong decision for each: H1 is the reveal decision in `onReasoningRowToggle` (it cannot tell the toggled row from the newest row), H2 is the follow decision in `followGrownTurn` (same blind spot), H3 is `timelineFollowsEnd` staying true after a programmatic scroll away from the end. If none fails (no path scrolls with these metrics), say so, keep the three tests as plain `it`, and stop: report to the coordinator that the unit metrics do not reproduce the packaged failure, so Phase 8's Linux-lab run at 1000x720 (Task 8.1) is the next step and Task 5.2 makes no code change. -8. [ ] Restore the `it.fails` marks on the confirmed paths and run the command of step 6. Expected: PASS. -9. [ ] Run `vp check src/components/ChatView.test.tsx` from `apps/web`. Expected: no errors. +1. [ ] Read the baseline (measured by the coordinator from the Linux lab logs of the build that became main): the packaged harness sets the viewport itself (`ensureMainSidebarOpen` calls `setDesktopUiWindowSize(1_000, 720)`), and `chat-reasoning-rows` PASSED on Linux WebKitGTK in two full runs (31 passing each). So the failure does not reproduce on Linux at 1000x720, and that viewport is not what distinguishes Windows. What differs on the Windows VM is WebView2 (Chromium) and device pixel ratio 2. Do not draw a conclusion about the cause from the Linux pass. +2. [ ] Optional cheaper first probe, on the Mac: run the web app in Chromium at device pixel ratio 2 (device metrics emulation) and perform the same sequence as `expandThoughtAwayFromEnd`: bring a finished reasoning row to the center with `scrollIntoView({ block: "center" })`, press its header as `toggleThought` does (a `pointerdown`, a `pointerup`, then `click`), and read the header's top before and 300 ms after. The app is started with `vp dev` from `apps/web` (`package.json` script `dev`); the thread with reasoning rows needs the fixture server and Claude shim the packaged spec uses (`apps/desktop/e2e/support/test-project.ts`, `provider-shims.ts`). If no browser route to that fixture exists, skip this step and say so; do not build one. If the probe reproduces (header moved more than 2 px), take the trace of step 4 from it, and the VM run only confirms the fix. If it does not, the trace comes from the VM. Expected: the change is in place, and the next command step checks it. +3. [ ] Add temporary instrumentation, every line prefixed `[DEBUG-r12]`, with `console.info("[DEBUG-r12]", JSON.stringify({ site, caller, mode, scroll, scrollDeltaToRevealEnd, maintainScrollAtEnd, t: performance.now() }))`. Place it at every scroll call site listed under Interfaces (`site` names the function; `caller` names who invoked it: `toggle`, `grownTurn`, `liveFollow`, `recheck`, `pageKey`, `pill`, `anchor`, `threadOpen`, `minimap`; `mode` is `timelineScrollModeRef.current`; `scroll` is `list.getState().scroll`; `scrollDeltaToRevealEnd` is the value the site used, or null). In `MessagesTimeline.tsx` log the value actually passed as `maintainScrollAtEnd` (the object or `false`) once per change, and log in the existing scroll handler (`handleScroll`) each scroll with `scrollTop` and the time since the last `[DEBUG-r12]` call-site line. A scroll with no call-site line before it is the list's own. Log nothing that is message text or a path. Do not run `vp check` while this is in the tree. Expected: the change is in place, and the next command step checks it. +4. [ ] Hand the instrumented working tree to the coordinator, who builds it and drives the run (the VM run of the instrumented build, or the Chromium probe of step 2 when it reproduced). Stop here until the `[DEBUG-r12]` lines come back. If none come back, report that and do not guess a path. Expected: the change is in place, and the next command step checks it. +5. [ ] Read the lines for the sequence around the toggle and classify by the first scroll after the `click`: (H1) a `site=onReasoningRowToggle` line about two frames after the click with `mode=anchoring-new-turn` and `scroll` near 0; (H2) a `site=followTimelineLiveEnd` line with `caller=grownTurn`; (H3) a scroll with no call-site line and `maintainScrollAtEnd` logged as an object, not `false`; (other) a scroll with no call-site line and `maintainScrollAtEnd=false`, or a line from any other site, which is then not one of the three: stop and report it to the coordinator with the lines. +6. [ ] Write the verdict comment: the winning path, the trace metrics (`scroll`, `scrollDeltaToRevealEnd`, `mode`, `maintainScrollAtEnd`, the growth in px, the frames between click and scroll), and the first wrong decision: for H1 the reveal decision in `onReasoningRowToggle` (it cannot tell the toggled row from the newest row), for H2 the follow decision in `followGrownTurn` (same blind spot), for H3 the end maintenance staying enabled after a programmatic scroll away from the end. +7. [ ] Remove all instrumentation by hand. Run `rg "\[DEBUG-r12\]" apps/web/src`. Expected: no matches. +8. [ ] Write the unit test that reproduces the named path with the trace's metrics, exercising the real toggle, as `it.fails` with a title containing `R12 scroll path`. Harness facts, from the file as it is: `ChatView.test.tsx` mocks `./chat/MessagesTimeline` with a stub that renders the real component only when `h.realTimeline` is true (as the tests that set `h.realTimeline = true` do), mocks `@legendapp/list/react` with a stub that renders rows and drops every other prop (so `maintainScrollAtEnd` is invisible there), and gives the list through `h.timelineList`. The existing #173 test leaves `h.realTimeline` false and calls `capturedProps("messagesTimeline")["onReasoningRowToggle"]()` directly, which skips the real header button; copying it unchanged would test the callback and not the toggle. So: for H1 and H2 add a new test in `ChatView.test.tsx` with `h.realTimeline = true`, a thread whose `reasoning` field holds one finished thought (the thread builder has `reasoning: []` at its default), `h.timelineList.getState().data` set to the rows `deriveMessagesTimelineRows` produces for it, the trace's `scroll`, `scrollLength` and a last-row growth, no `pointerdown` on the list, `onIsAtEndChange(false)` to stand for the programmatic scroll, and a click on the real header button `[data-reasoning-id] button` (an H2 test drives growth with `onRowSizeChanged({ index, previous, size })` after that click). For H3 add the test to `MessagesTimeline.test.tsx`, whose `@legendapp/list/react` stub exposes `data-maintain-scroll-at-end`: render the real timeline with `anchoredEndSpace` unset and `followsEnd` as the trace shows, and assert the attribute is absent after a non-newest toggle. State in the verdict comment which stub the test relies on and what it cannot see. +9. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: PASS (the `it.fails` test fails inside as intended). Then change that test to `it` and run again. Expected: FAIL with a scroll to an offset at or above the old end (H1, H2) or the attribute present (H3). Restore `it.fails` and run again. Expected: PASS. +10. [ ] Run `vp check apps/web/src/components/ChatView.test.tsx apps/web/src/components/chat/MessagesTimeline.test.tsx`. Expected: no errors. ### Task 5.2: Fix the first wrong decision the diagnosis names - Satisfies: R12. Scenarios pinned: Given a programmatic scroll to a reasoning row and a toggle, Then no scroll to the end is made within three animation frames; Given the newest reasoning row at the end of an anchored turn, Then the #173 reveal still happens. - Depends on: 5.1 -- Files: modify `apps/web/src/components/ChatView.tsx`; modify `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx` (H1 or H2); modify `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (H3 only); modify `apps/web/src/components/chat/MessagesTimeline.tsx` (H1 only); test `apps/web/src/components/ChatView.test.tsx` -- Interfaces: consumes the verdict comment and `anchoredTurnAfterProgrammaticScroll()` from Task 5.1. Produces, per confirmed hypothesis, exactly one of: (H1) `rowToggleNeedsEndReveal(input: { mode: TimelineScrollMode; scrollDeltaToRevealEnd: number | null; toggledRowIsNewest: boolean }): boolean` returning false when `toggledRowIsNewest` is false, with `onReasoningRowToggle(rowId: string): void` in `ChatView.tsx` and `MessagesTimeline.tsx` passing `reasoning.id`, and `toggledRowIsNewest` computed from `list.getState().data` as "no reasoning or work row follows the toggled row before the working row"; (H2) the same predicate applied in `onTimelineRowSizeChanged`, through a `reasoningToggleRowIdRef` set by `onReasoningRowToggle` for one frame pair so a size change caused by a non-newest toggle does not start `followGrownTurn`; (H3) when `onReasoningRowToggle` decides not to reveal, it calls `setTimelineFollowsEnd(false)`, and `onIsAtEndChange(true)` restores it, so `resolveTimelineMaintainScrollAtEnd({ anchored, followsEnd: false })` returns `false`. If several hypotheses were confirmed, apply the fix of each in the order H1, H2, H3, one test each. +- Files: modify `apps/web/src/components/ChatView.tsx`; modify `apps/web/src/components/chat/MessagesTimeline.tsx` (H1, H2); modify `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx` (H1, H2); modify `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (H3 only if the fix is in the predicate); test `apps/web/src/components/ChatView.test.tsx`, `apps/web/src/components/chat/MessagesTimeline.test.tsx` +- Interfaces: consumes the verdict comment and the `it.fails` test of Task 5.1. Writers and readers of `onReasoningRowToggle`, all of which a branch that changes it must cover: the type in `MessagesTimeline.tsx` `TimelineRowSharedState` (`onReasoningRowToggle: () => void`) and in `MessagesTimelineProps`; the destructure in `MessagesTimeline`; the row-context value built there (and its dependency list); the call in `ReasoningWorkEntryRow` (`onReasoningRowToggle()` inside `toggleExpanded`); the definition and the prop pass in `ChatView.tsx`; the test doubles `onReasoningRowToggle: () => {}` in `MessagesTimeline.test.tsx` and `useThreadFind.test.tsx` (still valid, as a function with fewer parameters is assignable); the assertion `expect(onReasoningRowToggle).toHaveBeenCalledTimes(2)` in `MessagesTimeline.test.tsx`; the direct call `(timeline["onReasoningRowToggle"] as () => void)()` in the #173 test of `ChatView.test.tsx`. Produces, by confirmed branch, and a branch may combine with another: + - H1 (the toggle's reveal): `onReasoningRowToggle: (reasoningId: string) => void` in `TimelineRowSharedState` and `MessagesTimelineProps`; `ReasoningWorkEntryRow` calls `onReasoningRowToggle(reasoning.id)`; `rowToggleNeedsEndReveal(input: { mode: TimelineScrollMode; scrollDeltaToRevealEnd: number | null; toggledRowIsNewest: boolean }): boolean` returns false when `toggledRowIsNewest` is false; new `isNewestReasoningRow(rows: ReadonlyArray<{ kind: string; groupedEntries?: ReadonlyArray<{ reasoning?: { id: string } }> }>, reasoningId: string): boolean` in `timelineScrollAnchoring.ts`, true when the row holding that id has no later row except of kind `working`; `ChatView.onReasoningRowToggle(reasoningId)` computes it from `legendListRef.current.getState().data`. + - H2 (the grown-turn follow): the same signature change and the same `isNewestReasoningRow`, plus `ChatView` keeps `toggledReasoningRowRef` (`{ index: number; newest: boolean } | null`), set by `onReasoningRowToggle(reasoningId)` and cleared after two animation frames; `onTimelineRowSizeChanged` returns early when `info.index` is the toggled row's index and `newest` is false. Because the H2 test of Task 5.1 starts from the real toggle, it now reaches the code that sets the ref. + - H3 (list-internal end maintenance): `ChatView.onReasoningRowToggle(reasoningId)` (same signature change) calls `setTimelineFollowsEnd(false)` when the row is not the newest, `onIsAtEndChange(true)` restores it, so the real `maintainScrollAtEnd` value passed by `MessagesTimeline` becomes `false`; if the trace showed the value was already `false` the branch is not H3. - Skills: tdd (red-green for the changed decision); vercel-react-best-practices (review of changed callbacks and refs) -- Read first: the verdict comment from Task 5.1; `apps/web/src/components/chat/MessagesTimeline.tsx` `ReasoningWorkEntryRow` (where `onReasoningRowToggle()` is called); `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end" (must still pass unchanged) +- Read first: the verdict comment from Task 5.1; `apps/web/src/components/chat/MessagesTimeline.tsx` `ReasoningWorkEntryRow`; `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end", whose fixture has anonymous list rows (`data: [{}, {}]`) and calls the callback with no argument; both change in this task (see step 2) - Tier: standard - TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts` exits 0, including the unchanged #173 test. +- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts` exits 0, including the #173 test with its updated fixture. Steps -1. [ ] Flip the confirmed tests of Task 5.1 from `it.fails` to `it`. They are the failing tests of this task. -2. [ ] Add a second test beside them, `the newest reasoning row at the end of an anchored turn still reveals the end after a toggle`, built on the #173 test's setup (a toggle of the newest row with a pointer press on its button). Assert exactly one `scrollToOffset` call with an offset above 2000, as the #173 test does. -3. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: FAIL on the flipped tests with the assertion messages from Task 5.1 step 6; the new reveal test PASSES. -4. [ ] Implement the fix named for the confirmed hypothesis in Interfaces, at the first wrong decision only. For H1 add the `toggledRowIsNewest` cases to `timelineScrollAnchoring.test.tsx` next to the existing `rowToggleNeedsEndReveal` cases: `mode: "anchoring-new-turn"`, `scrollDeltaToRevealEnd: 0`, `toggledRowIsNewest: false` gives false; `true` gives true. -5. [ ] Run the Done-when command. Expected: PASS, including "a pointer press on a Thinking row's toggle before a toggle at an anchored turn's end" for both rows of its table. -6. [ ] Prove the test can fail: revert the fix temporarily (make the new predicate or guard always true), run the Done-when command. Expected: FAIL on the flipped R12 test. Restore the fix and run it again. Expected: PASS. -7. [ ] Review the changed callbacks and refs against `vercel-react-best-practices` (stable callbacks, no state in a render path, no effect added). Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. +1. [ ] Flip the test of Task 5.1 from `it.fails` to `it`. It is the failing test of this task. +2. [ ] Update the #173 test fixture for the new signature, keeping its assertion (exactly one `scrollToOffset` call with an offset above 2000 for the newest row): give `getState().data` real rows (`[{ kind: "user", id: "anchor" }, { kind: "work", id: "row-1", groupedEntries: [{ reasoning: { id: "thought-1" } }] }]`), make `sizeAtIndex` and `positionAtIndex` follow those two rows, and call `onReasoningRowToggle("thought-1")`. Add a table row to that test: a later `work` row exists after `thought-1`, so the toggled row is not the newest, and the expected reveal count is 0. Update the `MessagesTimeline.test.tsx` assertion to `expect(onReasoningRowToggle).toHaveBeenCalledWith(REASONING_ID)` for both toggles. +3. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: FAIL with the assertion message recorded in Task 5.1 step 9. +4. [ ] Implement the branch(es) named in Interfaces at the first wrong decision only, and add the `rowToggleNeedsEndReveal` cases (mode `anchoring-new-turn`, `scrollDeltaToRevealEnd: 0`, `toggledRowIsNewest` false gives false, true gives true) and `isNewestReasoningRow` cases (last `work` row before `working`: true; a `work` row followed by another `work` row: false; unknown id: false) to `timelineScrollAnchoring.test.tsx`. +5. [ ] Run the Done-when command. Expected: PASS, including the #173 test. +6. [ ] Prove the test can fail: revert this task's change by hand (git stash is not allowed; undo the edits in `ChatView.tsx`, `MessagesTimeline.tsx` and `timelineScrollAnchoring.ts`) and rerun the Done-when command. Expected: FAIL on the R12 test. Restore the change and run it again. Expected: PASS. +7. [ ] Review the changed callbacks and refs against `vercel-react-best-practices` (stable callbacks, the ref cleared on a timer that is cancelled on unmount, no state in a render path). Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. 8. [ ] State the profiler coverage line for the final report: none of child processes, RPC methods, worktree removal, binaries, shutdown, events or findings is touched. ### Task 5.3: Stop the Host dashboard values view re-rendering on the clock tick -- Satisfies: R18. Scenario pinned: Given an open dashboard whose reading has not changed, When the 1 s tick fires three times, Then the values view and its charts do not render again. +- Satisfies: R18. Scenario pinned: Given an open dashboard whose reading has not changed, When the 1 s tick fires three times, Then the values view does not render again. - Depends on: none - Files: modify `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx`; test `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` -- Interfaces: consumes `HostDashboardValuesView({ values: HostDashboardValues; view: HostMetricsView | null; timestampFormat })` and `HostMetricBars`. `presentHostDashboard` builds a fresh `values` object and fresh `volumes`, `hiddenVolumes` and `statistics` arrays each render, so a reference check cannot work. Produces `const HostDashboardValuesView = memo(Inner, valuesViewPropsEqual)` where `valuesViewPropsEqual(previous, next): boolean` is true when `view` and `timestampFormat` are identical by reference and `values` is structurally equal (the same strings and rows). Search `apps/web/src` for an existing deep-equal helper first and use it; if none exists, write a local structural comparison in the sheet file. The `since` text carries the uptime, so it changes on its own schedule and correctly lets the view render then. +- Interfaces: consumes `HostDashboardValuesView({ values: HostDashboardValues; view: HostMetricsView | null; timestampFormat })`, whose only caller is `HostDashboardSheet` (``); `useVisibleSecondTick` is the only writer of the tick state (`nowMs`); `presentHostDashboard` builds a fresh `values` object and fresh `volumes`, `hiddenVolumes` and `statistics` arrays each render, so a reference check cannot work. Produces `const HostDashboardValuesView = memo(Inner, valuesViewPropsEqual)` where `valuesViewPropsEqual(previous, next): boolean` is true when `view` and `timestampFormat` are identical by reference and `values` is structurally equal. Search `apps/web/src` for an existing deep-equal helper first and use it; if none exists, write a local structural comparison in the sheet file. `values.since` carries the uptime text, so it changes on its own schedule and correctly lets the view render then. - Skills: tdd (red-green for the render count); vercel-react-best-practices (memo comparator review) -- Read first: `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` `useVisibleSecondTick` and `HostDashboardValuesView`; `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` test "dims the values after 3 seconds without an item" (fake timer setup to copy); `apps/web/src/components/hostDashboard/HostMetricBars.tsx` (the child whose renders are counted) +- Read first: `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` `useVisibleSecondTick` and `HostDashboardValuesView`; `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` test "dims the values after 3 seconds without an item" (fake timer setup to copy) and its file-level mocks (`../ui/sheet`, `../ui/tooltip`, `~/state/query` and others, all kept); `apps/web/src/components/hostDashboard/HostMetricBars.tsx` (wrapped in `memo`, so it does not re-render when its props are unchanged and therefore cannot count the parent's renders) - Tier: standard - TDD: red-green - Done when: from `apps/web`, `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0. Steps -1. [ ] Write the failing test `does not render the values view again when only the clock tick changed` in `HostDashboardSheet.test.tsx`. Use the fake timers of the "dims the values" test. Mock `./HostMetricBars` with a wrapper around the real component that increments a counter on each render. Render with `h.data = view()`, record the count, then `await act(async () => vi.advanceTimersByTime(3_000))` with the same data. Assert the counter is unchanged. Also assert the text `Last updated 3 s ago` still shows, so the sheet itself did re-render and only the values view was skipped. -2. [ ] Run `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx -t "clock tick"` from `apps/web`. Expected: FAIL with the counter higher than the recorded value. -3. [ ] Implement `valuesViewPropsEqual` and wrap `HostDashboardValuesView` in `memo` with it, in `HostDashboardSheet.tsx`. -4. [ ] Add a second assertion test `renders the values view again when a reading changes`: replace `h.data` with a view whose CPU differs and assert the counter rises and the new percent shows. Run the Done-when command. Expected: PASS. -5. [ ] Prove the test can fail: make `valuesViewPropsEqual` return false, run the Done-when command. Expected: FAIL on the clock tick test. Restore it and run again. Expected: PASS. -6. [ ] Review against `vercel-react-best-practices` (comparator cost is small next to a render; no inline object props added). Run `vp check apps/web/src/components/hostDashboard/HostDashboardSheet.tsx apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` and `vp run typecheck`. Expected: no errors. -7. [ ] Log in the final report that no user-visible string, state or layout changed, so the `UI.md` review has nothing new to review. +1. [ ] In `HostDashboardSheet.test.tsx` add a file-level `vi.mock("./HostMetricBars", async (importOriginal) => ...)` that exports `HostMetricBars` as a plain, non-memoized function component which increments `h.barRenders` and renders the real memoized component with the same props. It must not be memoized: a plain wrapper renders whenever its parent, the values view, renders, which is what the test counts. Add `barRenders: 0` to the hoisted `h` object and reset it in `beforeEach`. This wraps and does not replace the real chart, so the other tests in the file still see its real output. +2. [ ] Write the failing test `does not render the values view again when only the clock tick changed`. Use the fake timers of the "dims the values" test (`vi.useFakeTimers({ now, toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"] })`; time is advanced with `vi.advanceTimersByTime` inside `act`). Render with `h.data = view()` (one stable object), record `h.barRenders`, advance 3 000 ms with the same data, and assert `h.barRenders` is unchanged and the text `Last updated 3 s ago` shows (so the sheet itself did render). +3. [ ] Run `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx -t "clock tick"` from `apps/web`. Expected: FAIL with `h.barRenders` higher than recorded. +4. [ ] Implement `valuesViewPropsEqual` and wrap `HostDashboardValuesView` in `memo` with it, in `HostDashboardSheet.tsx`. +5. [ ] Add `renders the values view again when a reading changes`: replace `h.data` with a view whose CPU differs, and assert `h.barRenders` rises and the new percent shows. Run the Done-when command. Expected: PASS. +6. [ ] Prove the test can fail: revert this task's change by hand (remove the `memo` wrapper) and rerun the Done-when command. Expected: FAIL on the clock tick test. Restore it and run again. Expected: PASS. +7. [ ] Review against `vercel-react-best-practices` (comparator cost is small next to a render; no inline object props added). Run `vp check apps/web/src/components/hostDashboard/HostDashboardSheet.tsx apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` and `vp run typecheck`. Expected: no errors. +8. [ ] Log in the final report that no user-visible string, state or layout changed, so the `UI.md` review has nothing new to review. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md index 220d9cb4a..7e7b71dac 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md @@ -3,7 +3,7 @@ - Goal: A macOS native preview hides only under an open popup that overlaps it, the composer model picker reopens empty, and the read-only Markdown view in Files loads relative images from the workspace, each with a failing-first test. - Round: 1 - Depends on: none -- Owns: `apps/web/src/browser/browserSurfaceStore.ts`, `apps/web/src/browser/browserSurfaceSync.ts` and their tests (`browserSurfaceStore.test.ts`, `browserSurfaceSync.test.ts`), `apps/web/src/components/ui/useNativePreviewOcclusion.ts`, new `apps/web/src/components/ui/usePopupOcclusion.ts`, `apps/web/src/components/ui/popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx`, new `apps/web/src/components/ui/popup-occlusion.dom.test.tsx`, `apps/web/src/components/chat/ProviderModelPicker.tsx` and `ProviderModelPicker.test.tsx`, `apps/web/src/components/ChatMarkdown.tsx` and its tests (`ChatMarkdown.test.tsx`), the `RenderedMarkdownSurface` function in `apps/web/src/components/files/FilePreviewPanel.tsx` and `FilePreviewPanel.test.tsx` +- Owns: `apps/web/src/browser/browserSurfaceStore.ts`, `apps/web/src/browser/browserSurfaceSync.ts` and their tests (`browserSurfaceStore.test.ts`, `browserSurfaceSync.test.ts`), `apps/web/src/components/ui/useNativePreviewOcclusion.ts`, new `apps/web/src/components/ui/usePopupOcclusion.ts`, `apps/web/src/components/ui/popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx`, new `apps/web/src/components/ui/popup-occlusion.dom.test.tsx`, `apps/web/src/components/chat/ProviderModelPicker.tsx`, `ProviderModelPicker.test.tsx` and new `ProviderModelPicker.real.test.tsx`, `apps/web/src/components/ChatMarkdown.tsx` and its tests (`ChatMarkdown.test.tsx`), the `RenderedMarkdownSurface` function in `apps/web/src/components/files/FilePreviewPanel.tsx` and `FilePreviewPanel.test.tsx` - Must not touch: `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/hostDashboard/**`, `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/ui/tooltip.tsx` - Skills: `tdd` (every task changes behavior test-first), `vercel-react-best-practices` (React changes under apps/web), `better-interface` (picker state and image error text are user-visible) - Read first: `AGENTS.md` (completion rules); `UI.md` (preserve user work, honest empty and error states); `docs/architecture/overview.md` section on the native browser preview and Files rendered view (current behavior) @@ -32,70 +32,72 @@ Steps 3. [ ] Run `cd apps/web && vp test run --project unit src/browser`. Expected: FAIL with `isBrowserSurfaceOccluded is not a function` and a surface hidden by a disjoint lease. 4. [ ] Implement the `Map`, the two functions above and the `push` change (`visible: presentation.visible && !isBrowserSurfaceOccluded(presentation.rect, state.occlusionOwners)`). Keep `acquireBrowserSurfaceOcclusion()` with no argument meaning a null rectangle so `useNativePreviewOcclusion` and the dialogs behave as before. 5. [ ] Run `cd apps/web && vp test run --project unit src/browser src/components/ui/dialog.occlusion.dom.test.tsx`. Expected: PASS. -6. [ ] Prove a test can fail: make `isBrowserSurfaceOccluded` return true for every owner, run. Expected: the disjoint-rectangle tests FAIL. Restore and run. Expected: PASS. +6. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 3 command. Expected: FAIL. Restore it and rerun. Expected: PASS. 7. [ ] Run `vp check` on the changed files. Expected: no errors. ### Task 6.2: Popups hold a rectangle lease only while open -- Satisfies: R40, popup half. Given a macOS desktop host, an open Popover, Menu, Select, Combobox or Autocomplete popup, Then it leases its own rectangle; closing releases it; a popup kept mounted while closed holds no lease; tooltips hold none (D11); dialogs and sheets keep their full lease. +- Satisfies: R40, popup half. Given a macOS desktop host, an open Popover, Menu, Select, Combobox or Autocomplete popup, Then it leases its own rectangle and keeps it current when the popup moves without resizing (anchor scroll, repositioning); closing releases it; a popup kept mounted while closed holds no lease; tooltips hold none (D11); dialogs and sheets keep their full lease. - Depends on: 6.1 - Files: create `apps/web/src/components/ui/usePopupOcclusion.ts`, `apps/web/src/components/ui/popup-occlusion.dom.test.tsx`; modify `apps/web/src/components/ui/popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx`, `apps/web/src/components/ui/useNativePreviewOcclusion.ts` (replace the "Not for menus and popovers" comment with a pointer to the new hook) -- Interfaces: consumes `acquireBrowserSurfaceOcclusion(rect?)` from 6.1, `isDesktopHost` from `~/env`, `isMacPlatform(navigator.platform)` from `~/lib/utils`; produces `usePopupOcclusion(open: boolean, positionerRef: React.RefObject): void` which, while `open` and on macOS desktop, acquires a lease with the positioner's `getBoundingClientRect()` (rounded like `BrowserSurfaceSlot`), calls `lease.update` from a `ResizeObserver` on the positioner and on window `resize`, and releases on close or unmount. The `open` signal for each primitive is read from the Popup element's `data-open` attribute through a small `MutationObserver` inside the hook's caller component `PopupOcclusion({ positionerRef, popupRef })`, rendered inside each Popup; the first step below confirms that signal against the installed Base UI before the rest is built. -- Skills: tdd (red-green cycle); vercel-react-best-practices (effects, observers and cleanup) -- Read first: `apps/web/src/components/ui/useNativePreviewOcclusion.ts`; `apps/web/src/components/ui/dialog.occlusion.dom.test.tsx` (mocks and platform setup to copy); `apps/web/src/components/ui/popover.tsx` (`Portal keepMounted`); `apps/web/src/components/ui/menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx` (Positioner and Popup structure) +- Interfaces: consumes `acquireBrowserSurfaceOcclusion(rect?)` and the lease's `update` from 6.1, `isDesktopHost` from `~/env`, `isMacPlatform(navigator.platform)` from `~/lib/utils`; produces `usePopupOcclusion(open: boolean, positionerRef: React.RefObject): void` which, while `open` and on macOS desktop, acquires a lease with the positioner's `getBoundingClientRect()` (rounded like `BrowserSurfaceSlot`) and releases on close or unmount. Geometry signal: a `ResizeObserver` on the positioner, window `resize`, window `scroll` in capture phase, and a `requestAnimationFrame` loop that runs only while open, reads `getBoundingClientRect()` once per frame and calls `lease.update` only when a coordinate changed. The loop is what covers movement without resizing, including the positioner's own `transition-[top,left,right,bottom,transform]` in `popover.tsx`, which no observer reports. `PopupOcclusion({ positionerRef, popupRef })` is a child rendered inside each Popup; it reads the open state from the Popup element's `data-open` attribute (observed with a `MutationObserver`) and calls the hook. Step 3 confirms that signal against the installed Base UI before the rest is built. +- Skills: tdd (red-green cycle); vercel-react-best-practices (effects, observers, frame loop and cleanup) +- Read first: `apps/web/src/components/ui/useNativePreviewOcclusion.ts`; `apps/web/src/components/ui/dialog.occlusion.dom.test.tsx` (mocks `~/env` and the platform; no component under test is stubbed, copy it); `apps/web/src/components/ui/popover.tsx` (`Portal keepMounted`, the positioner transition); `apps/web/src/components/ui/menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx` (Positioner and Popup structure); every caller of the five popups is covered by the primitive change and needs no edit - Tier: most capable - TDD: red-green - Done when: `cd apps/web && vp test run --project unit src/components/ui/popup-occlusion.dom.test.tsx` exits 0 with every case passed Steps -1. [ ] Write the failing DOM test `popup-occlusion.dom.test.tsx` by copying the setup of `dialog.occlusion.dom.test.tsx`, with cases: (a) an open `PopoverPopup` adds one entry to `useBrowserSurfaceStore.getState().occlusionOwners` whose value is a non-null rectangle; (b) closing it removes the entry although the keep-mounted portal stays in the DOM; (c) a popover that never opened holds no entry; (d) the same for `MenuPopup` and `SelectPopup`, `ComboboxPopup` and `AutocompletePopup` (one case each); (e) a `TooltipPopup` open holds no entry; (f) on a non-macOS platform or `isDesktopHost` false, no entry. Stub `getBoundingClientRect` on the positioner to return a known rectangle and assert the lease value equals it. +1. [ ] Write the failing DOM test `popup-occlusion.dom.test.tsx` by copying the setup of `dialog.occlusion.dom.test.tsx`. Time is controlled by stubbing `requestAnimationFrame` with a queue the test flushes by hand (`flushFrames()`), so no real time passes. Cases: (a) an open `PopoverPopup` adds one entry to `useBrowserSurfaceStore.getState().occlusionOwners` whose value is a non-null rectangle; (b) closing it removes the entry although the keep-mounted portal stays in the DOM; (c) a popover that never opened holds no entry; (d) `MenuPopup`, `SelectPopup`, `ComboboxPopup` and `AutocompletePopup`, one open case each; (e) an open `TooltipPopup` holds no entry; (f) a non-macOS platform or `isDesktopHost` false holds no entry; (g) movement with no size change: stub the positioner's `getBoundingClientRect` with a mutable rectangle of fixed width and height; present a surface with `acquireBrowserSurface("tab").present({ x: 0, y: 0, width: 400, height: 300 }, true)`; start the popup disjoint from the surface, move it by changing only `x` and `y` into overlap, `flushFrames()`, and assert `isBrowserSurfaceOccluded` is true; move it out again and assert false; the lease's rectangle follows each move. 2. [ ] Run `cd apps/web && vp test run --project unit src/components/ui/popup-occlusion.dom.test.tsx`. Expected: FAIL with `occlusionOwners.size` 0 where an entry is expected. 3. [ ] Confirm the open signal: in the test, assert that the Popup element carries `data-open` while open and not after close for a keep-mounted popover. If the installed Base UI exposes a different attribute, use that one and write the actual name in the hook's doc comment. -4. [ ] Implement `usePopupOcclusion` and the `PopupOcclusion` child, and render it inside the Popup of the five primitives. Do not edit `tooltip.tsx`. -5. [ ] Run the same command. Expected: PASS. Also run `cd apps/web && vp test run --project unit src/components/ui/popover.test.tsx src/components/ui/combobox.test.tsx src/components/ui/autocomplete.test.tsx src/components/ui/dialog.occlusion.dom.test.tsx`. Expected: PASS (no regression, dialogs still hold a full lease). -6. [ ] Prove the test can fail: make the hook acquire the lease on mount regardless of `open`, run the DOM test. Expected: case (b) and (c) FAIL. Restore and run. Expected: PASS. -7. [ ] Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the `vercel-react-best-practices` review (observer cleanup, no work while closed). State in the final report that a real macOS run of the native preview was not made here. +4. [ ] Implement `usePopupOcclusion` and `PopupOcclusion`, and render it inside the Popup of the five primitives. Do not edit `tooltip.tsx`. +5. [ ] Run the step 2 command. Expected: PASS. Also run `cd apps/web && vp test run --project unit src/components/ui/popover.test.tsx src/components/ui/combobox.test.tsx src/components/ui/autocomplete.test.tsx src/components/ui/dialog.occlusion.dom.test.tsx`. Expected: PASS (no regression, dialogs still hold a full lease). +6. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 2 command. Expected: FAIL. Restore it and rerun. Expected: PASS. Then remove only the frame loop and rerun. Expected: case (g) FAILS while (a) to (f) pass; restore. +7. [ ] Run `cd apps/web && vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the `vercel-react-best-practices` review (observer and frame-loop cleanup, no work while closed). State in the final report that a real macOS run of the native preview was not made here. ### Task 6.3: The model picker reopens with an empty search -- Satisfies: R46. Given a query typed in the composer model picker, When the picker closes and opens again, Then the search is empty and the current model values show. +- Satisfies: R46. Given a query typed in the composer model picker, When the picker closes and opens again by any route (the trigger, the keyboard shortcut through the composer's imperative handle, or any external change of the `open` prop), Then the search is empty and the current model values show. - Depends on: none -- Files: modify `apps/web/src/components/chat/ProviderModelPicker.tsx`; test `apps/web/src/components/chat/ProviderModelPicker.test.tsx` -- Interfaces: consumes `ProviderModelPicker` props `open`, `onOpenChange`, `model`, `modelOptionsByInstance`; produces an opening counter in `ProviderModelPicker` (state, incremented in `setIsMenuOpen` when `open` becomes true) used as the `key` of `ModelPickerContent`, so each opening mounts it fresh. No prop changes. +- Files: modify `apps/web/src/components/chat/ProviderModelPicker.tsx`; test `apps/web/src/components/chat/ProviderModelPicker.test.tsx`; create `apps/web/src/components/chat/ProviderModelPicker.real.test.tsx` +- Interfaces: consumes `ProviderModelPicker` props `open`, `onOpenChange`, `model`, `modelOptionsByInstance`. Callers that open it: `ChatComposer.tsx` passes controlled `open={isComposerModelPickerOpen}` and opens it through `openModelPicker` and `toggleModelPicker` of its imperative handle (`setIsComposerModelPickerOpen`), and through `onOpenChange` from the popover. None of them calls the picker's own `setIsMenuOpen`, so a counter incremented there misses them. Produces, inside `ProviderModelPicker`, an opening counter advanced on the real closed-to-open transition of `isMenuOpen` (derived from `props.open ?? uncontrolledIsMenuOpen`): `const [wasMenuOpen, setWasMenuOpen] = useState(isMenuOpen); const [openings, setOpenings] = useState(0); if (isMenuOpen !== wasMenuOpen) { setWasMenuOpen(isMenuOpen); if (isMenuOpen) setOpenings((count) => count + 1); }` (a render-phase state adjustment, no effect), used as the `key` of `ModelPickerContent`. No prop changes; `ChatComposer.tsx` is read only. +- Harness (stated explicitly): `ProviderModelPicker.test.tsx` mocks `../ui/popover` (children always rendered, which stands in for keep-mounted) and replaces `ModelPickerContent` with a stub that has no search input. Change that stub explicitly: give it a `` with its own `useState("")` value and a mount counter in `captured`, so the remount can be observed; keep every existing assertion. Those stub tests only prove the picker remounts the content on each opening and forward its props. They do NOT prove the real search resets. The real behavior is proved by the new `ProviderModelPicker.real.test.tsx`, which uses the real `Popover` and the real `ModelPickerContent` (mock only data hooks it reads: `useClientSettings`, `useUpdateClientSettings`, the keybinding resolver, as `ModelPickerContent.test.tsx` does; open that file first). - Skills: tdd (red-green cycle); better-interface (picker state a person sees) -- Read first: `apps/web/src/components/chat/ProviderModelPicker.tsx` (`setIsMenuOpen`, `PopoverPopup`); `apps/web/src/components/chat/ModelPickerContent.tsx` (`searchQuery` state, focus layout effect); `apps/web/src/components/chat/ProviderModelPicker.test.tsx` (`renderPicker`, `click`, "opens and closes an uncontrolled picker around selection") +- Read first: `apps/web/src/components/chat/ProviderModelPicker.tsx` (`setIsMenuOpen`, `isMenuOpen`, `PopoverPopup`); `apps/web/src/components/chat/ModelPickerContent.tsx` (`searchQuery` state, focus layout effect); `apps/web/src/components/chat/ModelPickerContent.test.tsx` (mocks to copy for the real test); `apps/web/src/components/chat/ProviderModelPicker.test.tsx` (the mocks named above); `apps/web/src/components/chat/ChatComposer.tsx` (`openModelPicker`, `toggleModelPicker`, `ProviderModelPicker` usage with `open` and `onOpenChange`) - Tier: standard - TDD: red-green -- Done when: `cd apps/web && vp test run --project unit src/components/chat/ProviderModelPicker.test.tsx` exits 0 +- Done when: `cd apps/web && vp test run --project unit src/components/chat/ProviderModelPicker.test.tsx src/components/chat/ProviderModelPicker.real.test.tsx` exits 0 Steps -1. [ ] Write the failing test `reopening the picker shows an empty search` first, before any change: open, type `gpt` into the search input (`input` inside `[data-model-picker-content]`) with a change event, close with `onOpenChange(false)` (or select Escape), open again, assert the input value is `""` and the list shows every model. Add `reopening after the model prop changed shows the new current model in the list` (re-render with another `model`, close, open, assert the highlighted or selected item is the new one). -2. [ ] Run `cd apps/web && vp test run --project unit src/components/chat/ProviderModelPicker.test.tsx -t "reopening"`. Expected: the first test FAILS with the input value `gpt` (the popover is keep-mounted, so state survives). If it passes, the premise is wrong: record "R46: no defect, state does not survive" and stop; keep the test as proof. +1. [ ] Write the failing tests before any source change. In `ProviderModelPicker.real.test.tsx` (real components, a wrapper that owns `open` state and has external buttons that set it directly, as the composer's handle does): `reopening through the controlled prop shows an empty search` (open, type `gpt` with a change event on the search input, set `open` false, set it true; assert the input value is `""`), `reopening through the trigger shows an empty search`, and `reopening after the model prop changed lists the new current model`. In `ProviderModelPicker.test.tsx` after the harness change: `reopening remounts the content through every route` (trigger, controlled prop) asserting the stub's mount counter rises by one per opening and not on an unrelated re-render. +2. [ ] Run `cd apps/web && vp test run --project unit src/components/chat/ProviderModelPicker.real.test.tsx src/components/chat/ProviderModelPicker.test.tsx -t "reopening"`. Expected: the `reopening ...` tests FAIL with the input value `gpt` or an unchanged mount counter (the popover keeps the content mounted). If the real test passes, the premise is wrong: record "R46: no defect, state does not survive" in the final report, keep the tests as proof and stop. 3. [ ] Add the opening counter and key `ModelPickerContent` with it. -4. [ ] Run the same command. Expected: PASS. -5. [ ] Prove the test can fail: remove the `key`, run. Expected: FAIL. Restore and run. Expected: PASS. -6. [ ] Run `vp check` on the two files. Expected: no errors. Record the UI.md check (typing then closing does not need to preserve the query: an empty search is the expected start). +4. [ ] Run the step 2 command. Expected: PASS. +5. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 2 command. Expected: FAIL. Restore it and rerun. Expected: PASS. +6. [ ] Run `cd apps/web && vp check` on the changed files. Expected: no errors. Record the UI.md check: an empty search is the expected start of each opening. ### Task 6.4: Read-only Markdown loads relative images from the workspace - Satisfies: R47. Given the read-only rendered Markdown view in Files (rich editor still loading, file over 128 KiB, or content the editor cannot change safely), When the file has `![x](img/a.png)`, Then the image is read from the workspace relative to the Markdown file through `RichMarkdownImage`; a `../` path that leaves the workspace is refused with the loader's message; chat messages render exactly as before. - Depends on: none - Files: modify `apps/web/src/components/ChatMarkdown.tsx`, `apps/web/src/components/files/FilePreviewPanel.tsx` (only `RenderedMarkdownSurface`); test `apps/web/src/components/ChatMarkdown.test.tsx`, `apps/web/src/components/files/FilePreviewPanel.test.tsx` -- Interfaces: consumes `RichMarkdownImage({ workspace: RichMarkdownWorkspace, src: string, alt: string })` and `RichMarkdownWorkspace { environmentId, threadId, cwd, markdownPath }` from `apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx`; produces an optional `markdownPath?: string` prop on `ChatMarkdown` (`ChatMarkdownProps`), carried in `ChatMarkdownRenderState` with the existing `environmentId`, `threadRef` and `cwd`, and an `img` entry in `CHAT_MARKDOWN_COMPONENTS` (`MarkdownImage`, module scope, reads the render state) that renders `RichMarkdownImage` when `markdownPath`, `threadRef` and `cwd` are all present and a plain `` otherwise. `RenderedMarkdownSurface` passes `markdownPath={session.relativePath}`. +- Interfaces: consumes `RichMarkdownImage({ workspace: RichMarkdownWorkspace, src: string, alt: string })` and `RichMarkdownWorkspace { environmentId, threadId, cwd, markdownPath }` from `apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx`; produces an optional `markdownPath?: string` prop on `ChatMarkdown` (`ChatMarkdownProps`), carried in `ChatMarkdownRenderState` with the existing `environmentId`, `threadRef` and `cwd`, and an `img` entry in `CHAT_MARKDOWN_COMPONENTS` (`MarkdownImage`, module scope, reads the render state) that renders `RichMarkdownImage` when `markdownPath`, `threadRef` and `cwd` are all present and a plain `` otherwise. `RenderedMarkdownSurface` passes `markdownPath={session.relativePath}`. Callers of `ChatMarkdown` (`MessagesTimeline`, `PlanSidebar`, `ProposedPlanCard`, `FilePreviewPanel`) are the only render sites; the first three pass nothing and stay unchanged. +- Harness (stated explicitly): `FilePreviewPanel.test.tsx` replaces `~/components/ChatMarkdown` with a text-only `div` that records its props (`ui.record("ChatMarkdown", props)`) and also mocks `./richMarkdown/RichMarkdownSurface`. No image can reach a loader there, so that file only gets prop-forwarding tests: for each of the three read-only paths, the recorded `ChatMarkdown` props include `markdownPath` equal to the session's relative path. The behavior (loader reached, refusal text) is proved in `ChatMarkdown.test.tsx`, which renders the real `ChatMarkdown` and the real `RichMarkdownImage` and mocks only what `RichMarkdownImage.test.tsx` mocks (`@effect/atom-react` `useAtomValue`, `~/state/assets`, `~/state/environments`); open both files first. `ChatMarkdown.test.tsx` already mocks `./ui/menu` only; keep it. - Skills: tdd (red-green cycle); vercel-react-best-practices (module-scope components, stable context) -- Read first: `apps/web/src/components/ChatMarkdown.tsx` (`CHAT_MARKDOWN_COMPONENTS`, `ChatMarkdownRenderState`, the `renderState` memo); `apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx` and `richMarkdownImageSource.ts` (loader and refusal rules); `apps/web/src/components/files/FilePreviewPanel.tsx` (`RenderedMarkdownSurface`); `apps/web/src/components/files/richMarkdown/RichMarkdownImage.test.tsx` (how the loader is mocked) +- Read first: `apps/web/src/components/ChatMarkdown.tsx` (`CHAT_MARKDOWN_COMPONENTS`, `ChatMarkdownRenderState`, the `renderState` memo); `apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx` and `richMarkdownImageSource.ts` (loader and refusal rules); `apps/web/src/components/files/richMarkdown/RichMarkdownImage.test.tsx` (the mocks to reuse); `apps/web/src/components/files/FilePreviewPanel.tsx` (`RenderedMarkdownSurface`); `apps/web/src/components/files/FilePreviewPanel.test.tsx` (the mocks named above) - Tier: standard - TDD: red-green - Done when: `cd apps/web && vp test run --project unit src/components/ChatMarkdown.test.tsx src/components/files/FilePreviewPanel.test.tsx src/components/files/richMarkdown` exits 0 Steps -1. [ ] Write the failing test in `ChatMarkdown.test.tsx`: `renders a relative image through the workspace loader when a markdownPath is given` (mock `RichMarkdownImage` as in its own test; assert it receives `src: "img/a.png"` and a workspace with `markdownPath` `docs/readme.md`) and `renders an image unchanged without a markdownPath` (a plain `img` with the same `src`). -2. [ ] Write the failing test in `FilePreviewPanel.test.tsx`: for each of the three read-only paths (rich editor loading, file over 128 KiB via the too-large notice, unsafe content via the read-only notice) a relative image reaches `RichMarkdownImage`; for `../outside.png` the view shows the text "Only images inside this project are shown." (the loader's refusal). -3. [ ] Run `cd apps/web && vp test run --project unit src/components/ChatMarkdown.test.tsx src/components/files/FilePreviewPanel.test.tsx -t "image"`. Expected: FAIL: the image is a plain `img` with the relative `src`. -4. [ ] Add the prop, the render-state field, `MarkdownImage` and the `img` entry; pass `markdownPath` from `RenderedMarkdownSurface`. Keep `MessagesTimeline`, `PlanSidebar` and `ProposedPlanCard` call sites untouched. +1. [ ] Write the failing tests in `ChatMarkdown.test.tsx` (real components): `relative image reads from the workspace when a markdownPath is given` (assert the asset request carries `threadId` and `path` joined as `joinWorkspacePath(cwd, "docs/img/a.png")` for `markdownPath` `docs/readme.md` and `src` `img/a.png`), `relative image outside the workspace is refused with the loader's message` (`../../outside.png`; assert the text "Only images inside this project are shown."), and `image without a markdownPath stays a plain image` (an `img` with the original `src`, no asset request). +2. [ ] Write the failing prop-forwarding tests in `FilePreviewPanel.test.tsx`: `forwards the file path to ChatMarkdown in the read-only rendered view`, once per path (rich editor loading, too-large notice, unsafe-content notice), reading `ui` records. +3. [ ] Run `cd apps/web && vp test run --project unit src/components/ChatMarkdown.test.tsx src/components/files/FilePreviewPanel.test.tsx -t "relative image|image without|forwards the file path"`. Expected: FAIL (the image is a plain `img` with the relative `src`; no `markdownPath` in the recorded props). +4. [ ] Add the prop, the render-state field, `MarkdownImage` and the `img` entry; pass `markdownPath` from `RenderedMarkdownSurface`. 5. [ ] Run the step 3 command and then the Done when command. Expected: PASS, including the existing chat Markdown tests. -6. [ ] Prove a test can fail: make `MarkdownImage` ignore `markdownPath`, run. Expected: the workspace tests FAIL. Restore and run. Expected: PASS. -7. [ ] Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the `vercel-react-best-practices` review (components stay at module scope, no new closure in render). +6. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 3 command. Expected: FAIL. Restore it and rerun. Expected: PASS. +7. [ ] Run `cd apps/web && vp check` on the changed files and `vp run typecheck`. Expected: no errors. Record the `vercel-react-best-practices` review (components stay at module scope, no new closure in render). diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md index f450d71bb..dec8649dd 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md @@ -35,33 +35,40 @@ Steps 3. [ ] Change `is_running_image` so the `runtime_mount` check no longer requires `running_executable`: hidden when `fuse`-family, read-only, mount point not `/`, and (`runtime_mount` or (`named_after_image` and the executable lies under the mount point)). Update its doc comment: say that every `.mount_*` read-only FUSE mount is an AppImage runtime mount of some instance, and name the cost (a user FUSE volume mounted under a `.mount_` directory is hidden). 4. [ ] Update the old assertions on purpose: in `the_running_app_image_mount_is_hidden_before_it_is_read` the `/tmp/.mount_OtherY` volume is now hidden (`hidden.len() == 2`, `kept.len() == 2`) and the last assertion becomes "a `.mount_` mount is hidden without a known executable"; keep its doc comment true to the new behavior. Leave `a_read_only_fuse_root_holding_the_executable_stays_shown` and `only_the_app_image_identity_hides_a_fuse_mount` as they are; both must still pass. 5. [ ] Run `cargo test -p bibcode-server --lib host_metrics::volumes`. Expected: PASS. -6. [ ] Prove the test can fail: restore the `running_executable` requirement on `runtime_mount`, run the command of step 5. Expected: FAIL on the new test. Restore the new code and run it again. Expected: PASS. +6. [ ] Prove the test can fail: revert this task's change by hand (git stash is not allowed; put back the old `running_executable` requirement on `runtime_mount` in `is_running_image`) and rerun the command of step 5. Expected: FAIL on the new test. Restore the new code and run it again. Expected: PASS. 7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. 8. [ ] Profiler coverage line for the final report: none of the child process, RPC, worktree removal, binary, shutdown or event items is touched; the hidden-volume reason keeps using the existing `HiddenReason::System`. -### Task 7.2: Check for a dead webview before every test and keep the relaunch inside the test allowance +### Task 7.2: Check for a dead webview before every test and keep the whole recovery inside one deadline -- Satisfies: R15. Scenario pinned: Given the webview of the shared app died in an earlier test, When the next test of the same worker starts, Then the harness relaunches the app once, inside the mocha timeout, and that test runs on the new app. +- Satisfies: R15. Scenario pinned: Given the webview of the shared app died in an earlier test, When the next test of the same worker starts, Then the harness relaunches the app once, inside one overall deadline shorter than the mocha timeout, and that test runs on the new app. - Depends on: none - Files: modify `apps/desktop/e2e/wdio.conf.ts`; modify `apps/desktop/e2e/support/app-lifecycle.ts`; test `apps/desktop/e2e/support/app-lifecycle.test.ts` -- Interfaces: consumes `recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise`, `ensureLiveDesktopUiWebview(): Promise`, `relaunchDesktopUiApplication(): Promise` and `stopRunningDesktopUiApplication(): Promise`. Produces exported constants in `app-lifecycle.ts`, with the numbers the fix uses: `DESKTOP_UI_TEST_TIMEOUT_MS = 120_000` (the value `mochaOpts.timeout` in `wdio.conf.ts` now reads from it), `DESKTOP_UI_STOP_BUDGET_MS = 10_000` (the existing SIGTERM wait), `DESKTOP_UI_EXIT_WAIT_MS = 20_000` (was 30 s, the wait for the old process to leave), `DESKTOP_UI_SERVE_WAIT_MS = 50_000` (was 90 s, the wait for WebDriver to answer), `DESKTOP_UI_PLUGIN_WAIT_MS = 20_000` (the existing `waitforTimeout` of the plugin wait), and `DESKTOP_UI_RELAUNCH_WORST_CASE_MS = 100_000`, the sum of the four waits that applies after a dead probe. The margin to the 120 s allowance is 20 s for session reload and mock restore. -- Skills: tdd (red-green for the no-memory behavior and the budget sum) -- Read first: `apps/desktop/e2e/wdio.conf.ts` hooks `before` and `beforeTest` and the comment on `desktopUiSpecs` (the nested array puts all shared specs in one worker, so `before` runs once for all of them); `apps/desktop/e2e/support/app-lifecycle.ts` `recoverDeadDesktopUiWebview`, `relaunchDesktopUiApplication`, `ensureLiveDesktopUiWebview`; `apps/desktop/e2e/support/app-lifecycle.test.ts` describe `recoverDeadDesktopUiWebview`; `docs/testing/linux-desktop.md` and `docs/testing/windows-desktop.md` (the harness procedure they describe) +- Interfaces: consumes `recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise`, `ensureLiveDesktopUiWebview(): Promise`, `relaunchDesktopUiApplication(): Promise`, `stopRunningDesktopUiApplication(): Promise` and `waitForDesktopUiApplicationExit(pid: number, timeoutMs: number): Promise`. Callers of the functions that change: `ensureLiveDesktopUiWebview` is called from `wdio.conf.ts` hook `before`; `relaunchDesktopUiApplication` is also called, with no argument, from `apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts` (a spec this phase does not edit), so its new parameter must be optional; `stopRelaunchedDesktopUiApplication` is called from hook `after`. Produces, in `app-lifecycle.ts`: `DESKTOP_UI_TEST_TIMEOUT_MS = 120_000` (the value of `mochaOpts.timeout` in `wdio.conf.ts`, which now reads it); `DESKTOP_UI_RECOVERY_DEADLINE_MS = 100_000`; `interface RecoveryDeadline { readonly budgetMs: number; remainingMs(): number }`; `startRecoveryDeadline(budgetMs?: number): RecoveryDeadline` (default `DESKTOP_UI_RECOVERY_DEADLINE_MS`, measured with `Date.now()`); `withinRecoveryDeadline(deadline: RecoveryDeadline, step: string, operation: () => Promise): Promise`, which rejects with `Error("The webview recovery ran out of its ms during .")` when the remaining time reaches 0 before the operation settles, clears its timer when the operation settles first, and abandons (does not cancel) a still-running operation; `DeadWebviewRecovery.relaunch: (deadline: RecoveryDeadline) => Promise`; `recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery, deadline?: RecoveryDeadline): Promise`, which runs the probe, `isRunning`, `stopApplication` and `relaunch` each through `withinRecoveryDeadline` with the steps named `probe`, `liveness check`, `stop` and `relaunch`; `relaunchDesktopUiApplication(deadline?: RecoveryDeadline): Promise` (default `startRecoveryDeadline()`), whose exit wait, serve wait, `browser.reloadSession()`, `browser.tauri.restoreAllMocks()` and plugin wait each run through `withinRecoveryDeadline` and whose polling waits use `Math.min(, deadline.remainingMs())` (the existing 30 s exit cap and 90 s serve cap stay as upper bounds). The numbers: one deadline of 100 s covers the initial probe, the stop, the relaunch, `reloadSession` and the mock restore; the mocha timeout is 120 s, so 20 s remain for the rest of the hook (`resetDesktopUiConnectionCache`, `installDesktopUiMotionGuard`). WDIO lets a single command wait 120 s (`connectionRetryTimeout`), which is why every step is raced against the shared deadline instead of trusting the command's own timeout. This assumes WDIO runs `beforeTest` as a mocha hook under `mochaOpts.timeout`; if it does not, the deadline is still the bound that matters. +- Skills: tdd (red-green for the deadline and the hook) +- Read first: `apps/desktop/e2e/wdio.conf.ts` hooks `before` and `beforeTest`, and the comment on `desktopUiSpecs` (the nested array puts all shared specs in one worker, so `before` runs once for all of them); `apps/desktop/e2e/support/app-lifecycle.ts` `recoverDeadDesktopUiWebview`, `relaunchDesktopUiApplication`, `stopRunningDesktopUiApplication`, `ensureLiveDesktopUiWebview`; `apps/desktop/e2e/support/app-lifecycle.test.ts` describe `recoverDeadDesktopUiWebview` (its `deps` helper builds `probe`, `isRunning`, `stopApplication`, `relaunch` mocks; `relaunch` now receives a deadline); `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md`, `docs/testing/macos-desktop.md` - Tier: standard - TDD: red-green -- Done when: `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` exits 0, and `rg -n "ensureLiveDesktopUiWebview" apps/desktop/e2e/wdio.conf.ts` shows a call inside `beforeTest` before `resetDesktopUiConnectionCache`. +- Done when: from the repository root, `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` exits 0. Steps -1. [ ] In `app-lifecycle.test.ts` add the failing test `relaunches again when the webview dies a second time in the same worker`. Call `recoverDeadDesktopUiWebview` twice with the same dependency object whose `probe` rejects with `new Error("invalid session id")` both times (and a `relaunch` mock); expect `relaunch` called twice and both calls to resolve `true`. This pins that no once-per-worker state hides in the helper. Add the failing test `the relaunch waits fit inside the test allowance`: assert `DESKTOP_UI_STOP_BUDGET_MS + DESKTOP_UI_EXIT_WAIT_MS + DESKTOP_UI_SERVE_WAIT_MS + DESKTOP_UI_PLUGIN_WAIT_MS` equals `DESKTOP_UI_RELAUNCH_WORST_CASE_MS` and that value is at most `DESKTOP_UI_TEST_TIMEOUT_MS - 20_000`. -2. [ ] Run `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts`. Expected: FAIL with the new constants not exported (the first test may already pass; that is fine, it stays as proof). -3. [ ] In `app-lifecycle.ts` export the constants above and use them in `relaunchDesktopUiApplication` (the 30 s exit wait and the 90 s serve wait), in `stopRunningDesktopUiApplication` and `stopRelaunchedDesktopUiApplication` (the 10 s waits) and in the plugin `waitUntil`. Correct the comments: `ensureLiveDesktopUiWebview` is "the per-test probe of the shared app", and `recoverDeadDesktopUiWebview` runs "before a test", not "before a spec file". -4. [ ] In `wdio.conf.ts` call `await ensureLiveDesktopUiWebview()` as the first statement of `beforeTest`, before `resetDesktopUiConnectionCache`, and read `mochaOpts.timeout` from `DESKTOP_UI_TEST_TIMEOUT_MS`. Keep the call in `before` (the plugin-ready check needs a live webview at worker start) and fix its comment to say it covers worker start only. Add a line to the comment on `desktopUiSpecs`: the shared specs run in one worker, so `before` runs once for all of them and `beforeTest` carries the per-test probe. Expected: the config type-checks and the support test of step 1 passes. -5. [ ] Run `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts`. Expected: PASS. -6. [ ] Prove the test can fail: set `DESKTOP_UI_SERVE_WAIT_MS` to `90_000` temporarily and run the command of step 5. Expected: FAIL on the budget test. Restore the value and run it again. Expected: PASS. +1. [ ] In `app-lifecycle.test.ts` write the failing tests below. Time is controlled with `vi.useFakeTimers()` in a `beforeEach` of a new describe `recovery deadline` and `vi.useRealTimers()` in its `afterEach`; every delayed operation is a promise resolved by `setTimeout`, advanced with `await vi.advanceTimersByTimeAsync(ms)`; a test that abandons an operation advances past it before asserting, so no timer is left (`expect(vi.getTimerCount()).toBe(0)` at the end). + - `a probe that never answers fails at the deadline and relaunches nothing`: `probe` returns a promise that never settles; advance 100 000 ms; expect the rejection `The webview recovery ran out of its 100000 ms during probe.` and `relaunch` not called. + - `the stop and the relaunch share one deadline`: `probe` rejects with `new Error("invalid session id")`, `isRunning` resolves true, `stopApplication` resolves after 60 000 ms, `relaunch` records `deadline.remainingMs()` when called and then takes 50 000 ms; expect the recorded value between 39 000 and 40 000 and, after advancing 100 000 ms, the rejection naming `relaunch`. + - `a recovery whose steps fit completes`: probe rejects dead, stop 10 000 ms, relaunch 30 000 ms; expect `true`. + - `an operation that settles first clears its timer`: `withinRecoveryDeadline(startRecoveryDeadline(1_000), "step", async () => 7)` resolves 7 and `vi.getTimerCount()` is 0. + - `the recovery deadline leaves room in the test allowance`: `DESKTOP_UI_RECOVERY_DEADLINE_MS <= DESKTOP_UI_TEST_TIMEOUT_MS - 20_000`. + - `relaunches again when the webview dies a second time in the same worker`: call `recoverDeadDesktopUiWebview` twice with a fresh `startRecoveryDeadline()` each; expect `relaunch` called twice and both results `true`. + - `the beforeTest hook registers the probe before any browser command`: read `apps/desktop/e2e/wdio.conf.ts` as text (do not import it: it throws without a packaged binary), parse it with the TypeScript compiler API (`ts.createSourceFile`, `typescript` is a root dev dependency), find the property assignment `beforeTest` in the exported `config`, and assert that the first statement of its body is the expression `await ensureLiveDesktopUiWebview()`. Add the same assertion for `before`. This test fails if the hook is omitted or moved after `resetDesktopUiConnectionCache`; calling the helper twice cannot show that. +2. [ ] Run `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` from the repository root. Expected: FAIL; the deadline tests on the missing exports, the hook test with `beforeTest` not starting with the probe. +3. [ ] In `app-lifecycle.ts` implement the exports above and thread the deadline through `recoverDeadDesktopUiWebview` and `relaunchDesktopUiApplication`. Update the existing tests' `deps` helper so `relaunch` accepts the deadline argument. Correct the comments: `ensureLiveDesktopUiWebview` is "the per-test probe of the shared app", and `recoverDeadDesktopUiWebview` runs "before a test", not "before a spec file". +4. [ ] In `wdio.conf.ts` call `await ensureLiveDesktopUiWebview()` as the first statement of `beforeTest`, before `resetDesktopUiConnectionCache`, read `mochaOpts.timeout` from `DESKTOP_UI_TEST_TIMEOUT_MS`, keep the call in `before` (the plugin-ready check needs a live webview at worker start) and fix its comment to say it covers worker start only. Add to the comment on `desktopUiSpecs` that the shared specs run in one worker, so `before` runs once for all of them and `beforeTest` carries the per-test probe. Expected: the change is in place, and the next command step checks it. +5. [ ] Run the command of step 2. Expected: PASS. +6. [ ] Prove the tests can fail: revert this task's change to `wdio.conf.ts` by hand (remove the call from `beforeTest`) and rerun. Expected: FAIL on the hook test. Restore it. Then revert the `withinRecoveryDeadline` wrapping of the probe in `recoverDeadDesktopUiWebview` by hand and rerun. Expected: FAIL on `a probe that never answers fails at the deadline and relaunches nothing`. Restore it and rerun. Expected: PASS. 7. [ ] Review `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md` and `docs/testing/macos-desktop.md` for any statement about when the dead-webview probe runs or about the 30 s and 90 s waits; correct them in this change if they exist, and otherwise write "reviewed and remain accurate" in the final report. -8. [ ] Run `vp check apps/desktop/e2e/wdio.conf.ts apps/desktop/e2e/support/app-lifecycle.ts apps/desktop/e2e/support/app-lifecycle.test.ts` and `vp run typecheck`. Expected: no errors. -9. [ ] Record in the final report that the 90 s serve wait was never measured and 50 s is a bound that keeps the sum under the allowance; Phase 8's packaged runs record the real relaunch time, and a relaunch that needs more than 50 s is a finding, not a reason to raise the wait silently. +8. [ ] Run `vp check apps/desktop/e2e/wdio.conf.ts apps/desktop/e2e/support/app-lifecycle.ts apps/desktop/e2e/support/app-lifecycle.test.ts` and `vp run typecheck` from the repository root. Expected: no errors. +9. [ ] Record in the final report that the real relaunch time is unmeasured; Phase 8's packaged runs record it, and a recovery that hits the 100 s deadline is a finding, not a reason to raise the deadline silently. ### Task 7.3: Run the flaky hint test 50 times under CPU load and diagnose any failure @@ -91,8 +98,8 @@ Steps - Satisfies: R45. Scenarios pinned: Given a pairing offer request that the server rejects, Then one `warn` line carries the trace id and the reason text. Given a pairing credential that `consume_grant` refuses, Then one `warn` line carries the class of the refusal and nothing that identifies the credential, a device, an address or a path. - Depends on: none - Files: modify `apps/server/src/auth/http.rs` (`invalid_pairing_offer_response`); modify `apps/server/src/auth/service.rs` (`consume_grant`); test `apps/server/src/auth/http.rs` (the existing `tests` module); test `apps/server/src/auth/service.rs` (the `tests` module, next to the persisted-store tests that use `AuthService::new_with_persistence`) -- Interfaces: consumes (a) `fn invalid_pairing_offer_response(detail: &str) -> Response` in `http.rs`, whose callers pass a fixed literal or `PairingOfferInputError::to_string()` (fixed `thiserror` messages that hold no input text); (b) `async fn consume_grant(&self, credential: &str, proof_key_thumbprint: Option<&str>) -> Result` in `service.rs`, with its three rejection sites; (c) the existing `Repositories::get_auth_pairing_link_by_credential(&self, credential: String) -> Result>`, which selects the row with its `expires_at`, `consumed_at`, `revoked_at` and `proof_key_thumbprint` columns. Produces: `invalid_pairing_offer_response` logging at `warn` with the same target, `trace_id` field and message; and a private `fn pairing_rejection_class(...) -> &'static str` in `service.rs` returning one of these fixed tokens, with this precedence when several apply: `empty` (the trimmed credential is empty), `unknown` (no row or no in-memory pairing holds it), `revoked`, `already_used`, `expired`, `proof_key_mismatch` (the link requires a proof key and the presented thumbprint differs or is absent), `desktop_bootstrap_expired` (the desktop bootstrap credential matched but its time ran out), `unclassified` (the extra read failed). Each refusal logs `tracing::warn!(target: "bibcode_server::auth", class, "a pairing credential was refused")`, once, before `Err(AuthError::InvalidCredential)` is returned. The HTTP response is unchanged for both paths. -- What the code can tell apart today, with no schema change: in the in-memory path (`self.repositories` is `None`) every class above, because the pairing record is in hand. In the persisted path, `consume_auth_pairing_link` runs one `UPDATE ... WHERE credential = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND (proof_key_thumbprint IS NULL OR proof_key_thumbprint = ?) RETURNING ...` and returns `None` for every cause, so the coarse result is "refused". To classify, `consume_grant` makes one extra read on the refusal path only, `get_auth_pairing_link_by_credential`, which reads columns that already exist (no migration). A row consumed by a racing request between the two statements is reported as `already_used`, which is true. The credential column holds the credential text, so the lookup uses the presented text and the text is never logged. A credential too malformed to be told from an unknown one (any wrong text) is `unknown`; only the empty string is told apart as `empty`. +- Interfaces: consumes (a) `fn invalid_pairing_offer_response(detail: &str) -> Response` in `http.rs`, whose callers pass a fixed literal or `PairingOfferInputError::to_string()` (fixed `thiserror` messages that hold no input text); (b) `async fn consume_grant(&self, credential: &str, proof_key_thumbprint: Option<&str>) -> Result` in `service.rs`, with its three rejection sites; (c) the existing `Repositories::get_auth_pairing_link_by_credential(&self, credential: String) -> Result>`, which selects the row with its `expires_at`, `consumed_at`, `revoked_at` and `proof_key_thumbprint` columns. Produces: `invalid_pairing_offer_response` logging at `warn` with the same target, `trace_id` field and message; and a private `fn pairing_rejection_class(...) -> &'static str` in `service.rs` returning one of these fixed tokens, with this precedence when several apply: `empty` (the trimmed credential is empty), `unknown` (no row, or no in-memory pairing holds it), `revoked` (persisted rows only), `already_used`, `expired`, `proof_key_mismatch` (the link requires a proof key and the presented thumbprint differs or is absent), `desktop_bootstrap_expired` (the desktop bootstrap credential matched but its time ran out), `unclassified` (the extra read failed). Each refusal logs `tracing::warn!(target: "bibcode_server::auth", class, "a pairing credential was refused")`, once, before `Err(AuthError::InvalidCredential)` is returned. The HTTP response is unchanged for both paths. +- What the code can tell apart today, with no schema change, per storage mode. Persisted mode (`self.repositories` is `Some`): `consume_auth_pairing_link` runs one `UPDATE ... WHERE credential = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND (proof_key_thumbprint IS NULL OR proof_key_thumbprint = ?) RETURNING ...` and returns `None` for every cause; the row itself stays after a revoke, a use or an expiry. So `consume_grant` makes one extra read on the refusal path only, `get_auth_pairing_link_by_credential`, which selects existing columns (no migration), and can report `unknown`, `empty`, `revoked`, `already_used`, `expired` and `proof_key_mismatch`. A row consumed by a racing request between the two statements is reported as `already_used`, which is true. In-memory mode (`self.repositories` is `None`, as the plain `AuthService::new` builds it): `revoke_pairing` removes the record (`state.pairings.remove`), and `issue_pairing_for_subject` and `list_pairings` prune consumed, revoked and expired records, so a later lookup finds nothing. In-memory can therefore report `unknown` and `empty`, `already_used` and `expired` only while the record has not been pruned, `proof_key_mismatch`, and `desktop_bootstrap_expired` (the desktop bootstrap check comes first in `consume_grant`); a revoked in-memory pairing reports `unknown`. This satisfies R45, and no record is retained only to name the reason in a log. The credential column holds the credential text, so the lookup uses the presented text, and the text is never logged. Only the empty string is told apart as malformed; any other wrong text is `unknown`. - Skills: tdd (red-green for the log level, the class and the absence of the secret) - Read first: `apps/server/src/auth/service.rs` `consume_grant` and the persisted-store tests near `new_with_persistence` (the fixture to reuse: `mcp_test_service()` builds a service over a temporary database); `apps/server/src/persistence/repositories.rs` `consume_auth_pairing_link`, `PAIRING_RETURNING_SQL` and `get_auth_pairing_link_by_credential`; `apps/server/src/auth/http.rs` `invalid_pairing_offer_response` and its callers; `apps/server/tests/provider_terminal_supervisor.rs` `TraceCapture` (the tracing capture other server tests use: a `MakeWriter` over `Arc>>` installed with `tracing::subscriber::set_default`; no shared helper exists, so copy it into each `tests` module) - Tier: standard @@ -103,12 +110,15 @@ Steps 1. [ ] In each `tests` module add the capture writer (copy of `TraceCapture` and `TraceCaptureWriter`), installed with `tracing_subscriber::fmt().with_writer(capture).with_ansi(false).with_max_level(tracing::Level::DEBUG)` through `tracing::subscriber::with_default`, so a `debug` line is also seen. 2. [ ] In `http.rs` write the failing test `a_rejected_pairing_offer_is_logged_at_warn_with_its_reason_and_without_the_token`. Call `validate_pairing_offer_input("Test Device", "ftp://example.test/?token=pairing-secret-marker", "another-device", None)`, pass `&error.to_string()` of its `Err` to `invalid_pairing_offer_response` as the handler does, and assert the captured text contains `WARN`, `endpoint must be an http(s) URL` and `trace_id=`, and does not contain `pairing-secret-marker`. Add the case `invalid_pairing_offer_response("idempotency key is invalid")` expecting `WARN`. Assert the status is `400` and the JSON `reason` is still `invalid_pairing_offer`. -3. [ ] In `service.rs` write the failing test `a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential`, a table over both paths (in-memory service and the `mcp_test_service()`-style persisted service). Per path, create pairing links through the service's existing issuance call with the credential text `pairing-secret-marker-`, then drive each case and call `consume_grant` (or the public `create_browser_session`): `unknown` (present `pairing-secret-marker-none`), `empty` (present `""`), `revoked` (revoke the link first), `already_used` (consume once, then again), `expired` (a link issued with an expiry already past, or the clock-controlled equivalent the existing tests use), `proof_key_mismatch` (a link bound to a thumbprint, presented with a different one). Add `desktop_bootstrap_expired` on the in-memory service built with an expired desktop bootstrap. For each case assert the captured text has exactly one `WARN` line with `class=` and `a pairing credential was refused`, and does not contain `pairing-secret-marker`. Assert the call still returns `Err(AuthError::InvalidCredential)`. +3. [ ] In `service.rs` write the failing tests `a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential_persisted` and `..._in_memory` (the `a_refused_pairing_credential` prefix is what the selector matches). Credentials are obtained from `PairingCredentialResult.credential` (`issue_pairing(scopes, label)`, `issue_pairing_with_proof(scopes, label, thumbprint)`); an expired link comes from `issue_pairing_for_subject(scopes, label, "one-time-token", -1, AuthGrantMetadata::default(), None)` (an `expires_at` already in the past, so no sleeping and no clock control; wrap the returned `PairingIssuance::Reserved` the way `reserved_pairing` does; if the table refuses an expiry before creation, insert the row with `Repositories::create_auth_pairing_link` and a past `expires_at`). Every credential text starts with `pairing-secret-marker`. Persisted service: build it like `mcp_test_service()` (in-memory SQLite, migrations, `AuthService::new_with_persistence`). In-memory service: `AuthService::new(&config, vec![7_u8; 32])`. Cases and the class each must log: + - Persisted: `unknown` (a credential never issued), `empty` (`""`), `revoked` (`revoke_pairing(id)` then consume), `already_used` (consume twice), `expired` (the `-1` ttl link), `proof_key_mismatch` (`issue_pairing_with_proof` with one thumbprint, consumed with another). + - In-memory: `unknown` (never issued), `empty`, `unknown` for a revoked link (assert the class is `unknown`, not `revoked`, and say why in a comment: `revoke_pairing` removes the record), `already_used` (consume twice with no issue or list call in between), `expired` (the `-1` ttl link, consumed before any issue or list call), `proof_key_mismatch`, and `desktop_bootstrap_expired` (set `service.desktop_bootstrap = Some(DesktopBootstrap { credential: "pairing-secret-marker-desktop".into(), expires_at_ms: 0 })` on the owned service before use; the field is private to the module and the `tests` module is its child). + For each case install the capture of step 1 with `tracing::subscriber::with_default` around the awaited `consume_grant` call (a synchronous guard held across the await in a current-thread `#[tokio::test]`), assert the captured text has exactly one `WARN` line containing `class=` and `a pairing credential was refused`, does not contain `pairing-secret-marker`, and that the call returned `Err(AuthError::InvalidCredential)`. The capture is read after the call settles, so nothing is pending when asserting. 4. [ ] Run `cargo test -p bibcode-server --lib auth::http::tests::a_rejected_pairing_offer_is_logged_at_warn` and `cargo test -p bibcode-server --lib auth::service::tests::a_refused_pairing_credential`. Expected: FAIL; the first because the line is `DEBUG`, the second because no `WARN` line exists. 5. [ ] Change `tracing::debug!` to `tracing::warn!` in `invalid_pairing_offer_response`, nothing else there. 6. [ ] In `service.rs` add `pairing_rejection_class`, and log through it at the three refusal sites of `consume_grant` (desktop bootstrap expired, persisted `None` after the extra read, in-memory refusal). On the persisted path the extra read runs only when the update returned `None`; if it errors, use `unclassified`. Do not log the credential, a hash of it, the link id, the label, the subject, an address or a path. 7. [ ] Run the two commands of step 4. Expected: PASS. -8. [ ] Prove the tests can fail: temporarily put the credential text into the `warn!` call, run the service test. Expected: FAIL on the "never the credential" assertion. Remove it. Temporarily change `warn!` back to `debug!` in `invalid_pairing_offer_response`, run the http test. Expected: FAIL. Restore both and run again. Expected: PASS. +8. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed): change `warn!` back to `debug!` in `invalid_pairing_offer_response` and remove the `warn!` call in `consume_grant`, then run the two commands of step 4. Expected: FAIL in both. Restore the change and run them again. Expected: PASS. Separately put the credential text into the `warn!` call temporarily and run the service test. Expected: FAIL on the "never the credential" assertion; remove it. 9. [ ] Run `cargo test -p bibcode-server --test auth_http` (response bodies unchanged). Expected: PASS. 10. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. 11. [ ] Profiler coverage line for the final report: refusals are logged through `tracing::warn!` at an operational boundary, as `AGENTS.md` asks; nothing goes through `profiler::emit` or `record_finding`; the class is a fixed token and no content, token, hash, device name, address or path is logged. Note the cost: one extra indexed read on a refused credential, which an unauthenticated caller can trigger, so keep it a single query and add no retry. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md index 0bc9be895..be75712a2 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md @@ -1,78 +1,81 @@ # Phase 8: Lab runs and bounded probes -- Goal: Every requirement whose proof needs a packaged app, another operating system or a real pair has a recorded result, and each "decide by probe" item is decided. +- Goal: Every requirement whose proof needs a packaged app or another operating system has a recorded result, and each "decide by probe" item is decided. - Round: 2 - Depends on: 1, 2, 3, 4, 5, 6, 7 -- Owns: nothing in the repository. Lab scripts, logs, screenshots and probe scripts stay in the session scratch folder. +- Owns: nothing in the repository. Lab scripts, logs, screenshots and probe scripts stay in the session scratch folder or inside the lab container. - Must not touch: product source under `apps/web/src`, `apps/server/src`, `packages/**`. A defect found here goes back to the phase that owns the file, as a new task there. - Skills: bibcode-profiler (Profiler is on in every run and its data is read); mstack-diagnose (any failing run is diagnosed before a fix) - Read first: `docs/testing/README.md`, `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md`, `docs/testing/cross-platform-validation.md` (the real lab procedures); `docs/skills/bibcode-profiler/SKILL.md` (how to turn the Profiler on and read its findings); `AGENTS.md` (no manual steps for the user; sensitive data) -- Checkpoint: `progress.md` holds one log line per task below with the command, the counts and the profiler findings, each finding classed as caused by this change, pre-existing or a test artifact. +- Checkpoint: `rg -n "^- .* 8\.[1-4] Closed" docs/plans/2026-11-13-issue-batch-348-358/progress.md` finds four lines, each with the command, the counts and the profiler findings classed as caused by this change, pre-existing or a test artifact. -Rules for every task in this phase: the Profiler is on; a run's numbers go into the plan log, never into a living runbook; nothing is downloaded except this repository's own release assets through `gh release download`; no run touches the user's live BiBCode instance or their own Git host logins. +Rules for every task in this phase: the Profiler is on; a run's numbers go into the plan log, never into a living runbook; nothing is downloaded except this repository's own release assets through `gh release download`; no run touches the user's live BiBCode instance or their own Git host logins; a long task logs after each numbered step so it can resume from the log. ## Tasks -### Task 8.1: Linux lab run of the packaged list, with the two discriminating runs +### Task 8.1: Linux lab run of the packaged list, with the webview-kill and route-change probes -- Satisfies: R12 (the 1000x720 run), R20, spec Verification (Linux lab) -- Depends on: 5.2, 7.2 +- Satisfies: R12 (guard against a Linux regression), R15 (real proof), R20, R35 (the marker survives a relaunched web process), spec Verification (Linux lab) +- Depends on: 4.2, 5.2, 7.2 - Files: none in the repository -- Interfaces: consumes the Linux lab image `bibcode-linux-lab:22.04` (rebuild it from `docs/testing/linux-desktop.md` and the Linux leg of `.github/workflows/desktop-ui-smoke.yml` when it is missing); produces log lines in `progress.md` +- Interfaces: consumes the existing Linux lab container `bibcode-linux-lab` (image `bibcode-linux-lab:22.04`, cache volume `bibcode-linux-lab-cache`), which holds a source tree, a build cache and the lab's run script; consumes the per-test probe of Task 7.2 and the reload marker of Task 4.2 (its key and the DOM state to assert are in Task 4.2's Interfaces); produces log lines in `progress.md` - Skills: bibcode-profiler (findings of the run are classified) -- Read first: `docs/testing/linux-desktop.md` (build and run procedure); `.github/workflows/desktop-ui-smoke.yml` (the exact packaged commands) -- Tier: standard +- Read first: `docs/testing/linux-desktop.md` (build and run procedure); `.github/workflows/desktop-ui-smoke.yml` Linux leg (the exact packaged commands the lab mirrors); `apps/desktop/e2e/wdio.conf.ts` (how `BIBCODE_E2E_SPEC` selects specs) +- Tier: most capable - TDD: exempt (a pure command run with recorded results) -- Done when: the packaged default list passes in the lab, `chat-reasoning-rows` passes at a 1000x720 viewport, and the R20 numbers are in the log +- Done when: `docker exec bibcode-linux-lab cat /exit-code` prints `0` for the default-list run, and the log holds the default-list counts, the webview-kill result, the reload-marker result and the R20 numbers Steps -1. [ ] Check free disk with `df -h /`. Expected: at least 60 GB free; otherwise stop and report. -2. [ ] Build the branch head in the lab with `CARGO_BUILD_JOBS=4`. Expected: the packaged app builds. -3. [ ] Run the packaged default list, Profiler on, with `GST_PLUGIN_PATH` set as the runbook says. Expected: every spec passes; a failure is diagnosed, not rerun blindly. -4. [ ] Run `chat-reasoning-rows` with the window at 1000x720. Expected: PASS (before Phase 5's fix this run was the discriminator; the baseline result is in the log from the start of execution, see plan decision P3). -5. [ ] R20: in one packaged run, switch between Settings and a thread ten times, then read the profiler data. Expected: record the longest main-thread stall and the web process's memory at start and end. A `client.stall` or `memory.leak-suspect` or `memory.leak-likely` finding that carries a trace opens a diagnosis task in the phase that owns the traced code; with no such finding, log "no finding with a trace; nothing changed". -6. [ ] Log every profiler finding of the run with its class (caused by this change, pre-existing, test artifact). +1. [ ] Run `df -h /` and `docker ps --filter name=bibcode-linux-lab`. Expected: at least 60 GB free and the container listed. If the image or container is absent, stop and report: its Dockerfile was not kept, and rebuilding it is a decision for the user. +2. [ ] Copy the branch head into the container as a `git archive` and build the packaged app with `CARGO_BUILD_JOBS=4`. Expected: an AppImage for the branch head exists. +3. [ ] Run the packaged default list under `xvfb-run` with the Profiler on and `GST_PLUGIN_PATH` set as the runbook says. Expected: every spec passes, including `chat-reasoning-rows` (the harness sets a 1000x720 viewport itself); a failure is diagnosed, not rerun blindly. +4. [ ] R15: start a run of two shared specs and, between the first and the second test, end the web process of the app by its PID (found as the child of the app's own PID, never by name). Expected: the first test after the kill relaunches the app once inside its time allowance and passes or fails on its own assertions, and the tests after it run on the new app; before Task 7.2's change the same scenario fails every later shared spec. +5. [ ] R35: with unsaved Files edits and an allowed remote install simulated by the harness's update mock, reload the page, then end the web process by its PID so the app starts a new one. Expected: after each, the installing dialog is shown and saving is held, as Task 4.2's Interfaces state; if the marker does not survive the relaunched web process, reopen Task 4.2 with that result. +6. [ ] R20: in one packaged run, switch between Settings and a thread ten times, then read the profiler data. Expected: record the longest main-thread stall and the web process's memory at start and end. A `client.stall`, `memory.leak-suspect` or `memory.leak-likely` finding that carries a trace opens a diagnosis task in the phase that owns the traced code; with no such finding, log "no finding with a trace; nothing changed". +7. [ ] Log every profiler finding of the runs with its class (caused by this change, pre-existing, test artifact). -### Task 8.2: Mixed-version probe against the previous release's server +### Task 8.2: Mixed-version probe against the latest released server - Satisfies: R19 - Depends on: 1.6 - Files: none in the repository (the probe script lives in the session scratch folder) -- Interfaces: consumes `scripts/resolve-previous-release-tag.ts` (the previous release tag), that release's Linux server asset, and the current `@bibcode/client-runtime` terminal RPC functions; produces a log line with each call's outcome +- Interfaces: consumes the latest published release of this repository (its tag from `gh release list --limit 1`, which is older than this branch's changes), that release's Linux aarch64 server package and `bibcode-server-SHA256SUMS`, and the current `@bibcode/client-runtime` terminal RPC functions; produces a log line with each call's outcome - Skills: mstack-diagnose (a failing legacy path is diagnosed before any fix) -- Read first: `scripts/resolve-previous-release-tag.ts` (how the tag is resolved); `packages/client-runtime/src/state/terminal.ts` (the attach, resize and keybinding calls); `docs/testing/cross-platform-validation.md` sections on older servers (what "legacy path" means for each call) +- Read first: `packages/client-runtime/src/state/terminal.ts` (the attach, resize and keybinding calls); `docs/testing/cross-platform-validation.md` sections on older servers (what "legacy path" means for each call); `docs/user/server-installation.md` and `docs/cloud/environment-auth.md` (how the headless server is started and how a client pairs with a fresh server) - Tier: most capable - TDD: exempt (a probe whose result is recorded; a failure becomes a red-green task in the owning phase) -- Done when: `rg -n "8.2 Closed" docs/plans/2026-11-13-issue-batch-348-358/progress.md` finds the log line, and it states, for attach, resize and keybindings, "legacy path, no error" or names the failing call and the new task that fixes it +- Done when: `rg -n "8\.2 Closed" docs/plans/2026-11-13-issue-batch-348-358/progress.md` finds the log line, and it states, for attach, resize and keybindings, "legacy path, no error" or names the failing call and the new task that fixes it Steps -1. [ ] Resolve the previous release tag with `node scripts/resolve-previous-release-tag.ts`. Expected: a tag older than this branch's version. -2. [ ] Inside the Linux lab container, fetch that release's server asset with `gh release download ` from this repository only, into a new empty directory, and start it headless on a loopback port with an isolated data home. Expected: the server answers its health route. -3. [ ] From a node script that imports the current client runtime, connect, read the server's capabilities, then call terminal attach, resize and the keybindings read. Expected: capabilities hold no `terminalSizeHandOver`; each call returns through its legacy path with no error. -4. [ ] Stop the server by its own PID and remove only the directory this task created. Expected: no leftover process. -5. [ ] Log the outcome. A failing call opens a task in the phase that owns the failing code. +1. [ ] Run `gh release list --limit 1 --json tagName --jq '.[0].tagName'`. Expected: a tag such as `v0.15.3` whose commit is an ancestor of `main`. +2. [ ] Inside the Linux lab container, in a new empty directory, run `gh release download --pattern 'bibcode-server-*aarch64*' --pattern 'bibcode-server-SHA256SUMS'` and check the package against the checksum file. Expected: the checksum matches; a mismatch stops the task. +3. [ ] Unpack the package without installing it system-wide and start the server headless on a loopback port with an isolated data home, as `docs/user/server-installation.md` describes. Expected: the server answers its health route. +4. [ ] From a node script that imports the current client runtime, pair with the server as `docs/cloud/environment-auth.md` describes for a fresh server, read its capabilities, then call terminal open and attach, a resize, and the keybindings read. Expected: capabilities hold no `terminalSizeHandOver`; each call returns through its legacy path with no error. +5. [ ] Stop the server by its own PID and remove only the directory this task created. Expected: no leftover process. +6. [ ] Log the outcome. A failing call opens a task in the phase that owns the failing code. ### Task 8.3: Windows VM runs -- Satisfies: R12 (Windows proof), R13 (12 whole-suite runs), spec Verification (Windows) -- Depends on: 5.2, 3.1 +- Satisfies: R12 (the live trace and the Windows proof), R13 (12 whole-suite runs), spec Verification (Windows) +- Depends on: 5.1, 3.1 - Files: none in the repository -- Interfaces: consumes the Windows 11 arm64 VM through the wrapper procedure in `docs/testing/windows-desktop.md`; produces log lines in `progress.md` +- Interfaces: consumes the Windows 11 arm64 VM through the wrapper procedure in `docs/testing/windows-desktop.md`; consumes the temporary `[DEBUG-r12]` instrumentation of Task 5.1 for the trace run and the fix of Task 5.2 for the proof run; produces the trace lines for Task 5.1 and log lines in `progress.md` - Skills: bibcode-profiler (the packaged runs have the Profiler on) - Read first: `docs/testing/windows-desktop.md` (build, test and packaged procedures for Windows) -- Tier: standard +- Tier: most capable - TDD: exempt (a pure command run with recorded results) -- Done when: the log holds `chat-reasoning-rows` PASS at the viewport where it failed (1000x720 CSS px), 12 of 12 whole-suite passes of `cargo test -p bibcode-server` for the removal test, and exit 0 for fmt, Clippy, `vp check` and typecheck +- Done when: `rg -n "8\.3 Closed" docs/plans/2026-11-13-issue-batch-348-358/progress.md` finds the log line, and it holds `chat-reasoning-rows` PASS on the fixed build, 12 of 12 whole-suite passes of `cargo test -p bibcode-server` for the removal test, and exit 0 for fmt, Clippy, `vp check` and typecheck Steps -1. [ ] Copy the branch head into the VM as the runbook says and build. Expected: the build succeeds. -2. [ ] Run `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `vp check`, `vp run typecheck`. Expected: exit 0 each. -3. [ ] Run `cargo test -p bibcode-server` 12 times, one after the other, with no other load-heavy job on the Mac. Expected: `removal_refuses_when_an_admission_does_not_drain_before_the_deadline` passes in 12 of 12; any other failure is logged with its name and compared with `main`. -4. [ ] Run the packaged `chat-reasoning-rows` spec. Expected: PASS; the expanded header moves at most 2 px. -5. [ ] Log the results and the profiler findings with their class. +1. [ ] Trace run, when Task 5.1 asks for it: copy the instrumented build into the VM, run the packaged `chat-reasoning-rows` spec, and hand the `[DEBUG-r12]` lines back to Task 5.1. Expected: the spec fails as in the issue and the lines name the scrolling path. Log the lines. +2. [ ] After Task 5.2: copy the branch head into the VM as the runbook says and build. Expected: the build succeeds. Log it. +3. [ ] Run `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `vp check`, `vp run typecheck`. Expected: exit 0 each. Log it. +4. [ ] Run the packaged `chat-reasoning-rows` spec. Expected: PASS; the expanded header moves at most 2 px. Log it. +5. [ ] Run `cargo test -p bibcode-server` 12 times, one after the other, with no other load-heavy job on the Mac, logging after each run. Expected: `removal_refuses_when_an_admission_does_not_drain_before_the_deadline` passes in 12 of 12; any other failure is logged with its name and compared with `main`. +6. [ ] Log the profiler findings of the packaged runs with their class. ### Task 8.4: Load runs on the Mac @@ -92,4 +95,3 @@ Steps 2. [ ] Run `cargo test -p bibcode-server --lib removal_refuses_when_an_admission_does_not_drain_before_the_deadline` 50 times in a loop that counts exit codes. Expected: 50 passes, 0 failures. 3. [ ] Stop exactly the PIDs from step 1. Expected: none left. 4. [ ] Log the count. A failure reopens Task 3.1. - diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md index 19d9129ad..52b47302b 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-09-docs-gates-and-pull-request.md @@ -7,7 +7,7 @@ - Must not touch: product source and tests (a failing gate goes back to the phase that owns the file) - Skills: better-writing (plain, exact wording in docs and the pull request body) - Read first: `AGENTS.md` (Task Completion Requirements, Testing Runbook Maintenance, Profiler coverage, Sensitive Data); `docs/README.md` (the docs index and what counts as living documentation); `spec.md` (the dispositions each issue needs before it may be closed) -- Checkpoint: `vp run check:markdown-links` exits 0, every command of Task 9.2 exits 0, and the pull request's `Closes` list equals the issues whose items all have a log line with evidence. +- Checkpoint: `vp run check:markdown-links` exits 0, every command of Task 9.3 exits 0, and the pull request's `Closes` list equals the issues whose items all have a log line with evidence. ## Tasks @@ -34,7 +34,7 @@ Steps - Satisfies: R5, R41 (documented behavior), spec Verification (runbooks reviewed) - Depends on: 9.1, 8.3 -- Files: modify `docs/architecture/overview.md`; modify `docs/architecture/rpc-and-orchestration.md`; modify `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md` and `docs/testing/macos-desktop.md` only where a procedure changed; modify `CHANGELOG.md` +- Files: modify `docs/architecture/overview.md`; modify `docs/architecture/rpc-and-orchestration.md`; modify `docs/testing/cross-platform-validation.md` (webview recovery text); modify `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md` and `docs/testing/macos-desktop.md` only where a procedure changed; modify `CHANGELOG.md` - Interfaces: consumes the `terminalSizeHandOver` capability (Task 1.6) and the Unicode 11 width mode (Phase 1) - Skills: better-writing (states guarantees and limits without marketing words) - Read first: `docs/architecture/overview.md` terminal and Profiler sections (the text that now changes) @@ -44,9 +44,9 @@ Steps Steps -1. [ ] Update the terminal section of `overview.md`: terminals count widths by Unicode 11, a new terminal retries WebGL after an earlier failure, and the development size record exists and holds no content. Expected: each statement matches the code. +1. [ ] Update `overview.md` where this batch contradicts it: (a) the pull-request lookup text that says a failure is never reused now states the 5-second failure backoff and that a remembered request is cleared by a failed lookup once its last success is more than 5 minutes old; (b) the terminal text that gates the size hand-over on `terminalUserInputClaim` now names `terminalSizeHandOver`; (c) terminals count widths by Unicode 11, a new terminal retries WebGL after an earlier failure, and the development size record exists and holds no content; (d) the quiesce sequence names the `pull-request-lookups` step. Expected: `rg -n "never reused|terminalUserInputClaim" docs/architecture/overview.md` shows no sentence that contradicts the code. 2. [ ] Update `rpc-and-orchestration.md` with the `terminalSizeHandOver` capability and what a client does without it. Expected: names equal the contracts in `packages/contracts/src`. -3. [ ] Review the three desktop runbooks against the changed procedures (the per-test webview probe). Change what is stale; record the others as reviewed and accurate. Expected: no execution-specific numbers are added to a living runbook. +3. [ ] In `docs/testing/cross-platform-validation.md`, replace the text that describes worker-only webview recovery and cascading failures after a web process crash with the per-test probe and its single overall deadline. Then review the three desktop runbooks against the changed procedures. Change what is stale; record the others as reviewed and accurate. Expected: no execution-specific numbers are added to a living runbook. 4. [ ] Add the unreleased changelog entries, one line per user-visible change, in the file's existing style. Expected: no issue is described as fixed that the log does not prove. 5. [ ] Run `vp run check:markdown-links`. Expected: exit 0. @@ -67,7 +67,7 @@ Steps 1. [ ] Put the pinned Rust toolchain first on `PATH` and run `cargo fmt --all --check`. Expected: exit 0. 2. [ ] Run `cargo clippy --workspace --all-targets -- -D warnings`. Expected: exit 0. 3. [ ] Run `cargo test -p bibcode-server` and `cargo test -p bibcode-desktop`. Expected: 0 failed. -4. [ ] Run `vp check --no-fmt`, `vp check` on the changed files, `vp run typecheck`, `vp run test`, `vp run check:contracts` and `vp run check:markdown-links`. Expected: exit 0 each (the app's own local settings file may fail whole-tree formatting; it is not part of the change). +4. [ ] Run `vp check --no-fmt` on the whole tree, `vp check `, `vp run typecheck`, `vp run test`, `vp run check:contracts` and `vp run check:markdown-links`, as the spec's Verification section states. Expected: exit 0 each. Whole-tree `vp check` runs in CI on a clean checkout; locally the desktop app's ignored settings file fails its formatting step. 5. [ ] Review every changed component and hook under `apps/web` against `vercel-react-best-practices`, and every changed screen, dialog, notice and message against `UI.md`. Expected: each finding is fixed in its owning task or logged with the reason it stays. 6. [ ] Write the profiler coverage line: which items of the `AGENTS.md` coverage list the batch touches (a new quiesce step, a new removal mode, new warn logs) and how each stays covered. Expected: one line per item. 7. [ ] Read `git diff main...HEAD --stat` and `git status --short`, and search the added lines, commit messages and plan files for local machine paths, private hosts, real names and credentials. Expected: none; no generated or debug file is in the diff. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/plan.md b/docs/plans/2026-11-13-issue-batch-348-358/plan.md index ea9288452..e19f90b7f 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/plan.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/plan.md @@ -1,7 +1,7 @@ # Issue batch #348 and #351 to #358: terminal panel, Git, worktree, update guard and web follow-ups Status: Draft -Spec: spec.md (Status line: Draft, sha256 2df2631120ee) +Spec: spec.md (Status line: Draft, sha256 3b06d6886431) Repository: this repository Base: main @@ -30,8 +30,8 @@ Base: main 1. A mirror window must never reflow at its own size when it repaints after a font load. Pinned by Task 1.1 (a fit mock that really resizes). 2. Shutdown order: lookups end before `git-writes` and the profiler teardown, and a lookup admitted after shutdown cannot panic or hang. Pinned by Task 2.4. 3. A cancelled lookup must not be remembered as a failure, or a leaving caller poisons the key for 5 seconds. Pinned by Task 2.5. -4. A failed desktop state query must never release the unsaved-edits block. Pinned by Task 4.2. -5. A popup kept mounted while closed must hold no occlusion lease, or the native preview stays hidden. Pinned by Task 6.2. +4. A failed desktop state query must never release the unsaved-edits block, and the block must survive a reload and a relaunched web process. Pinned by Task 4.2 and Task 8.1. +5. A popup kept mounted while closed must hold no occlusion lease, and a popup that moves without resizing must update its lease. Pinned by Task 6.2. ## Out of scope @@ -81,19 +81,19 @@ Phases in the same round run in parallel. A phase depends only on phases in earl | `apps/web/package.json` (dependency `@xterm/addon-unicode11` 0.9.0) | modify | 1 | `vp install`, then the R5 xterm test | | `packages/contracts/src/environment.ts`, `packages/contracts/fixtures/**`, `packages/shared/src/testSupport.ts` | modify | 1 | `vp run check:contracts` | | `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs` | modify | 1 | `cargo test -p bibcode-server --lib lifecycle` | -| `apps/server/src/git/process.rs`, `apps/server/src/git/sign_in.rs` | modify | 2 | `cargo test -p bibcode-server --lib sign_in:: index_lock_cleanup_` | +| `apps/server/src/git/process.rs`, `apps/server/src/git/sign_in.rs` | modify | 2 | `cargo test -p bibcode-server --lib -- sign_in:: index_lock_cleanup_` | | `apps/server/src/source_control/open_pull_request_lookups.rs`, `apps/server/src/source_control/pull_request.rs` | modify | 2 | `cargo test -p bibcode-server --lib open_pull_request_lookups::` | | `apps/server/src/git/broadcaster.rs`, `apps/server/src/production/git_vcs.rs`, `apps/server/src/production/runtime.rs` | modify | 2 | Phase 2 checkpoint | | `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/diagnostics/profiler/worktree_audit.rs` | modify | 3 | `cargo test -p bibcode-server --lib worktree` | | `apps/server/src/persistence/repositories.rs`, `apps/server/tests/repositories.rs`, `apps/server/src/production/orchestration_effects.rs`, `apps/server/tests/profiler_worktree_audit.rs`, `apps/server/src/mcp/tools/worktrees.rs` | modify | 3 | `cargo test -p bibcode-server --test repositories` and `--test profiler_worktree_audit` | -| `apps/web/src/components/files/unsavedFileEdits.ts`, `UnsavedChangesCloseDialog.tsx` and their tests; `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` and its test (only if Task 4.4 finds the action unreachable) | modify | 4 | Phase 4 checkpoint | +| `apps/web/src/components/files/unsavedFileEdits.ts`, `UnsavedChangesCloseDialog.tsx` and their tests; `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`, `apps/web/src/routes/__root.tsx` and their tests (only if Task 4.4 finds the action unreachable) | modify | 4 | Phase 4 checkpoint | | `scripts/desktop-event-names.test.ts` | create | 4 | `vp test run scripts/desktop-event-names.test.ts` | -| `apps/web/src/components/ChatView.tsx` and `ChatView.test.tsx`; `apps/web/src/components/chat/timelineScrollAnchoring.ts`, `MessagesTimeline.logic.ts` and their tests | modify | 5 | Phase 5 checkpoint | +| `apps/web/src/components/ChatView.tsx` and `ChatView.test.tsx`; `apps/web/src/components/chat/timelineScrollAnchoring.ts`, `MessagesTimeline.logic.ts`, `MessagesTimeline.tsx` and their tests | modify | 5 | Phase 5 checkpoint | | `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and its test | modify | 5 | Phase 5 checkpoint | | `apps/web/src/browser/browserSurfaceStore.ts`, `browserSurfaceSync.ts` and their tests | modify | 6 | from `apps/web`: `vp test run --project unit src/browser` | | `apps/web/src/components/ui/useNativePreviewOcclusion.ts`, `popover.tsx`, `menu.tsx`, `select.tsx`, `combobox.tsx`, `autocomplete.tsx` | modify | 6 | the new popup occlusion DOM test | | `apps/web/src/components/ui/usePopupOcclusion.ts` and its DOM test | create | 6 | the new popup occlusion DOM test | -| `apps/web/src/components/chat/ProviderModelPicker.tsx` and its test; `apps/web/src/components/ChatMarkdown.tsx` and its test; `apps/web/src/components/files/FilePreviewPanel.tsx` and its test | modify | 6 | Phase 6 checkpoint | +| `apps/web/src/components/chat/ProviderModelPicker.tsx`, its test and a new `ProviderModelPicker.real.test.tsx`; `apps/web/src/components/ChatMarkdown.tsx` and its test; `apps/web/src/components/files/FilePreviewPanel.tsx` and its test | modify | 6 | Phase 6 checkpoint | | `apps/server/src/diagnostics/host_metrics/volumes.rs`; `apps/server/src/auth/http.rs`, `apps/server/src/auth/service.rs` | modify | 7 | Phase 7 checkpoint | | `apps/server/tests/production_server_terminal_rpc.rs` (only if the R44 runs fail there) | modify | 7 | the 50-run loop of Task 7.3 | | `apps/desktop/e2e/wdio.conf.ts`, `apps/desktop/e2e/support/app-lifecycle.ts` and its test | modify | 7 | `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` | @@ -113,10 +113,10 @@ Parallel conflicts: none. Round 1's seven phases own disjoint files. Two points | R4 | 1.4 | | R5 | 1.10, 9.2 | | R6 to R11 | not planned: withdrawn from the spec, own folder for #350 (D2) | -| R12 | 5.1, 5.2, 8.1, 8.3 | +| R12 | 5.1, 5.2, 8.3 (8.1 guards Linux) | | R13 | 3.1, 8.3, 8.4 | | R14 | 7.1 | -| R15 | 7.2 | +| R15 | 7.2, 8.1 | | R16 | 1.8 | | R17 | 1.5 | | R18 | 5.3 | @@ -136,7 +136,7 @@ Parallel conflicts: none. Round 1's seven phases own disjoint files. Two points | R32 | 3.3, 9.1 | | R33 | 3.4 | | R34 | 3.5 | -| R35 | 4.2 | +| R35 | 4.2, 8.1 | | R36 | 4.3 | | R37 | 4.1 | | R38 | 4.5 | @@ -153,8 +153,8 @@ Parallel conflicts: none. Round 1's seven phases own disjoint files. Two points ## End-to-end verification - `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test -p bibcode-server`, `cargo test -p bibcode-desktop`: exit 0 (Task 9.3). -- `vp check`, `vp run typecheck`, `vp run test`, `vp run check:contracts`, `vp run check:markdown-links`: exit 0 (Task 9.3). -- Linux lab: the packaged default list passes with the Profiler on; `chat-reasoning-rows` passes at 1000x720; the R20 numbers are logged (Task 8.1). +- `vp check --no-fmt` on the whole tree and `vp check` on every changed file, `vp run typecheck`, `vp run test`, `vp run check:contracts`, `vp run check:markdown-links`: exit 0 (Task 9.3). +- Linux lab: the packaged default list passes with the Profiler on; the webview-kill and reload-marker checks pass; the R20 numbers are logged (Task 8.1). - Windows VM: `chat-reasoning-rows` passes; the removal test passes in 12 of 12 whole-suite runs; fmt, Clippy, `vp check` and typecheck exit 0 (Task 8.3). - Mac: the removal test passes 50 of 50 under load (Task 8.4); the hint test passes 50 of 50 under load or its failure is fixed (Task 7.3). - Previous release's server: attach, resize and keybindings answer through their legacy paths (Task 8.2). @@ -173,7 +173,7 @@ Parallel conflicts: none. Round 1's seven phases own disjoint files. Two points - P1. #350 leaves this plan (spec D2). Alternatives: keep its seven phases here (blocks nine issues on a design that failed review). Decided by: agent's call, to confirm with the user before the push. - P2. One plan folder for nine issues, one phase per file-ownership area rather than per issue, so that seven lanes can run at once without sharing a file. Alternatives: one phase per issue (three issues share `ThreadTerminalPanel.tsx`). Decided by: agent's call. -- P3. Before round 1 the coordinator runs the packaged `chat-reasoning-rows` spec on the base commit in the Linux lab at 1000x720 and logs the result, so Task 5.1 starts from a known reproduction or a known absence of one. It is a coordinator step, not a task, because every lab task depends on the fixes. Decided by: agent's call. +- P3. The baseline for Task 5.1 was taken during planning and is in the log: the harness sets a 1000x720 viewport itself and `chat-reasoning-rows` passes on Linux, so the live trace comes from Chromium at device pixel ratio 2 or from the Windows VM (Task 8.3 step 1, which runs as soon as Task 5.1 asks, ahead of the rest of Phase 8). Decided by: agent's call. - P4. Skills were named from the session's installed list by the planner (`tdd`, `vercel-react-best-practices`, `better-interface`, `better-writing`, `mstack-diagnose`, `bibcode-profiler`); `mstack-find-skills` was not run for a wider search. A task runs without a specialist where its Skills line says "none". Decided by: agent's call. - P5. Probe scripts for Task 8.2 are not committed: a one-off check does not earn a maintained script and its test. Decided by: agent's call. - P6. The plan review is by Codex (`gpt-6.1-sol`, effort high), and approval follows the standing rule of spec D3. Decided by: user's standing rule. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/progress.md b/docs/plans/2026-11-13-issue-batch-348-358/progress.md index 7a051e196..3de57a59e 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/progress.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/progress.md @@ -1,9 +1,9 @@ # Progress: Issue batch #348 and #351 to #358 Plan: plan.md -Spec: spec.md (sha256 2df2631120ee) +Spec: spec.md (sha256 3b06d6886431) Plan status: Draft -Plan review: none yet +Plan review: Codex gpt-6.1-sol (effort high), round 1, needs rework; 22 findings handled, see reviews/plan-review-1.md Executor: none Branch: claude/open-issues-pr-dc3c33 @@ -59,3 +59,4 @@ Branch: claude/open-issues-pr-dc3c33 ## Log - 2026-11-13 Plan written. Sources: ten issues exported to `sources/`, `evidence.md`, `triage.md`. #350 moved to `../2026-11-13-terminal-screen-350/` after its design review returned "needs rework". +- 2026-11-13 Baseline for Task 5.1 (plan decision P3): the packaged harness sets a 1000x720 viewport itself, and `chat-reasoning-rows` passed on Linux WebKitGTK in two full lab runs of the build that became `main` (31 passing each). No reproduction on Linux; the Windows VM (WebView2, device pixel ratio 2) is the signal. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md b/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md index affc20f01..a12e0d898 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-1.md @@ -101,3 +101,30 @@ Consistency: all **47 task IDs and titles** match `progress.md`, and the spec ha - What exact automated scenario kills a webview and proves the next test recovers? Phase 7 promises that later evidence, but Phase 8 does not schedule the injection (`PHASE-07:10`; `PHASE-08:30`). No files changed. Validation was limited to source inspection, in-memory probes, task/hash comparison, and the two failing command-parser checks described above; no builds, suites, or native runs were performed. + +## Handling + +Every finding was checked against the cited code by the author of the phase it names (read-only), then triaged with the advisor. All 22 were accepted; none contradicted a decision of the spec or the user. + +- 1 (R35, Task 4.2): fixed. A `sessionStorage` marker written when the window allows the install restores the block on load; only a confirmed "no remote install" answer clears it; a failing query retries. The marker's survival across a relaunched web process is checked in the packaged app (Task 8.1). +- 2 (Task 2.4): fixed. Every flight's token is a child of one shutdown token, whether or not it has a cache entry; the cancelled result's type is stated; tests cover an invalidated flight, no cache room and a retained waiter. +- 3 (Task 2.6): fixed. The late-refresh callback handles a failed lookup too, with a stream test. +- 4, 5 (Tasks 5.1, 5.2): fixed. The diagnosis now starts from a live trace of the real app (the baseline showed no Linux reproduction, so the trace comes from Chromium at device pixel ratio 2 or the Windows VM) and logs the real `maintainScrollAtEnd` value; the unit test reproduces only the path the trace names, through the real toggle; the callback signature change and its callers are listed for every fix branch. +- 6 (Task 1.6): fixed. Both entries into the hand-over state are gated on the new capability, with tests for snapshot, attached and resized carrying a reply claim. +- 7, 11 (Tasks 6.3, 6.4): fixed. The reset follows the real closed-to-open transition, including the composer's controlled path; the stubbed harnesses are changed explicitly and behavior tests use the real components. +- 8 (Task 6.2): fixed. The lease follows movement without a size change (scroll and a frame loop while open), with a movement-only test. +- 9 (Task 7.2): fixed. One 100-second deadline covers the whole recovery; a test asserts that `beforeTest` starts with the probe. +- 10 (Task 7.4): fixed. Classes are promised per storage mode; a revoked in-memory pairing logs `unknown`. +- 12 (Task 3.3): fixed. The existing single profiler test is extended with both rollback arms; each audit call has an assertion that fails without it. +- 13 (Task 4.4): fixed. The conditional branch owns the root route's host mounts and their tests. +- 14 (Task 8.2): fixed. The probe uses the latest published release (`gh release list`), checks the package against the checksum file, and names the docs for starting and pairing. +- 15, 17, 18, 21 (Phase 2): fixed. Paused time and settlement in the backoff tests; the delay is injected through the loader seam; filters use `--`; the type is `StatusBroadcaster`. +- 16 (Task 3.2): fixed. The can-fail proof reverts the removal caller to deleting by path. +- 18 (Task 1.4, plan.md): fixed. Selectors match their test titles; the File map command uses `--`. +- 19 (Task 9.2): fixed. The overview's lookup and hand-over statements and the shared runbook's webview-recovery text are named for update. +- 20 (R12): fixed differently. The harness already runs at 1000x720 everywhere and the spec passes on Linux, so a second Linux viewport would show nothing; the spec's R12 now states that and takes the trace from Chromium at device pixel ratio 2 or the Windows VM. +- 22 (Task 9.3): fixed in the spec. Verification now states whole-tree `vp check --no-fmt` plus `vp check` on changed files locally, because the desktop app writes an ignored settings file into the worktree; CI runs whole-tree `vp check` on a clean checkout. +- Task-size remarks (2.1 with 2.2, 1.3): deferred. They do not change what gets built and the IDs are referenced across files. +- Open questions: the reload signal is the marker above; the lab image is used as it exists and the task stops if it is absent; the webview-kill scenario is Task 8.1 step 4. + +Beyond the listed findings, each author applied two rules to their whole file: every "prove the test can fail" step reverts the task's own change, every extended test was read against its mocks, every selector matches its task's test names, and every caller and writer of changed state is listed. Spec R42 was also narrowed (the WebGL warning names a stage and an error type, never raw error text) because four existing tests require it. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/spec.md b/docs/plans/2026-11-13-issue-batch-348-358/spec.md index 5a31acd4c..3770b8c34 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/spec.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/spec.md @@ -101,7 +101,7 @@ Every terminal in the client SHALL count columns by Unicode 11 (the `@xterm/addo ### R12. Reasoning row keeps its place WHEN the reader has brought a reasoning row into view away from the turn's end and expands or collapses it, the row's header SHALL stay within 2 px of where it was. This is the invariant the packaged spec `chat-reasoning-rows` asserts in `expandThoughtAwayFromEnd` (scroll the row to the center, toggle, compare the header's position). -- Diagnose first, before any fix. Step 1: run that spec in the Linux lab at a 1000x720 viewport. A failure there is a platform-independent reproduction (WebView2 is not needed); it does not by itself make the viewport the cause, so the viewport is then varied on its own. Step 2: in a unit test with the spec's metrics, record which code path scrolls (the toggle's end reveal, the grown-turn follow, or the list's end maintenance). The fix goes at the first wrong decision that step 2 names; no fix is chosen before it. +- Diagnose first, before any fix. Known on 2026-11-13: the packaged harness already runs at a 1000x720 viewport on every platform, and the spec passes there on Linux (WebKitGTK), so neither that viewport nor the shared code alone reproduces it; Windows differs by WebView2 (Chromium) and a device pixel ratio of 2. Step 1: get a live trace of which code path scrolls (the toggle's end reveal, the grown-turn follow, or the list's end maintenance, with the real value of each decision), from Chromium at device pixel ratio 2 if that reproduces it, otherwise from the Windows VM. Step 2: a unit test reproduces that path with the trace's metrics. The fix goes at the first wrong decision the trace names; no fix is chosen before it. - Given the reader scrolled a reasoning row to the center without a wheel or touch gesture (a programmatic scroll), When the row is toggled, Then no scroll to the end is made within three animation frames. - Given the newest reasoning row at the end of an anchored turn, Then the existing reveal still happens (the #173 behavior, asserted by `expandStreamingThoughtAtEnd`). - Proof on Windows: the packaged `chat-reasoning-rows` spec passes in the Windows VM at the viewport where it failed. @@ -228,7 +228,7 @@ On macOS desktop, an open Popover, Menu, Select, Combobox or Autocomplete popup The client SHALL apply the typed-last size hand-over rule only when the server advertises `terminalSizeHandOver`. A server without it gets the old behavior. ### R42. WebGL after a failure -A new terminal in the same viewport SHALL try WebGL again after an earlier context loss, and the fallback warning SHALL include the cause. +A new terminal in the same viewport SHALL try WebGL again after an earlier context loss, and the fallback warning SHALL name the stage that failed (import failed, context lost) and the error's type name. It SHALL NOT carry raw error text: four existing tests require that, and raw text can hold paths. ### R43. Tall grid (decide by test) A test records rows and mount height between mount and first snapshot after a reload. IF rows exceed the fitted rows, the terminal SHALL be fitted before it is shown; otherwise the result is recorded. @@ -248,7 +248,7 @@ The read-only rendered Markdown view in Files SHALL load a relative image from t ## Verification - `cargo fmt --all --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test -p bibcode-server`; `cargo test -p bibcode-desktop`. -- `vp check`; `vp run typecheck`; `vp run test`; `vp run check:contracts`; `vp run check:markdown-links`. +- `vp check --no-fmt` on the whole tree and `vp check` on every changed file (the desktop app writes an ignored local settings file into an app-made worktree, which fails whole-tree formatting and is not part of the change; CI runs whole-tree `vp check` on a clean checkout); `vp run typecheck`; `vp run test`; `vp run check:contracts`; `vp run check:markdown-links`. - Linux lab: the packaged default list with the Profiler on, plus the R20 run. - Windows VM: `chat-reasoning-rows` (R12), the R13 runs, fmt, Clippy and `cargo test -p bibcode-server`. - Review of changed React against `vercel-react-best-practices` and of changed UI against `UI.md`. From 45fdc32bcc37e052d34b4c2092b0680998e36d6c Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:31:09 -0300 Subject: [PATCH 003/115] docs(plan): approve the issue-batch plan after its second Codex review Records how the 13 findings of round 2 were handled, and the state of the #350 design rework. Co-Authored-By: Claude Fable 5.2 --- .../phases/PHASE-01-terminal-panel-fixes.md | 162 +++++++++++------- .../PHASE-02-git-and-pull-request-lookups.md | 84 ++++----- ...SE-03-worktree-records-and-removal-test.md | 2 +- .../phases/PHASE-04-update-install-guard.md | 55 +++--- ...E-05-reasoning-row-scroll-and-dashboard.md | 77 ++++----- ...SE-06-popups-picker-and-markdown-images.md | 2 + .../PHASE-07-server-odds-and-e2e-harness.md | 20 ++- .../phases/PHASE-08-labs-and-probes.md | 2 +- .../2026-11-13-issue-batch-348-358/plan.md | 8 +- .../progress.md | 17 +- .../reviews/plan-review-2.md | 107 ++++++++++++ .../2026-11-13-issue-batch-348-358/spec.md | 8 +- .../2026-11-13-terminal-screen-350/README.md | 30 ++++ 13 files changed, 383 insertions(+), 191 deletions(-) create mode 100644 docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-2.md create mode 100644 docs/plans/2026-11-13-terminal-screen-350/README.md diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md index 510499ea4..5da13cf08 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-01-terminal-panel-fixes.md @@ -3,11 +3,11 @@ - Goal: The terminal panel re-measures and repaints after a late font load, a late renderer creation and a same-size return. It keeps a bounded, content-free record of size decisions for development. It says when Fit is refused, uses a dedicated hand-over capability, retries WebGL in a new terminal and counts columns by Unicode 11. R16 and R43 are decided by tests that stay as proof. Every change has its interaction test green. - Round: 1 - Depends on: none -- Owns: `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`, `apps/web/src/components/terminalSizing.ts`, `apps/web/src/components/terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts`, `apps/web/src/components/terminalSizeDiagnostics.ts` (new), `apps/web/src/components/terminalSizeDiagnostics.test.ts` (new), `apps/web/src/components/terminalUnicode.ts` (new), `apps/web/src/components/terminalUnicode.xterm.test.ts` (new), `apps/web/package.json`, `pnpm-lock.yaml` (updated by the executor's install only), `packages/contracts/src/environment.ts`, `packages/contracts/src/environment.test.ts`, `packages/contracts/fixtures/**` (regenerated by `check:contracts` only), `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts` +- Owns: `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`, `apps/web/src/components/terminalSizing.ts`, `apps/web/src/components/terminalSizing.xterm.test.ts` (new), `apps/web/src/components/terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts`, `apps/web/src/components/terminalSizeDiagnostics.ts` (new), `apps/web/src/components/terminalSizeDiagnostics.test.ts` (new), `apps/web/src/components/terminalUnicode.ts` (new), `apps/web/src/components/terminalUnicode.xterm.test.ts` (new), `apps/web/package.json`, `pnpm-lock.yaml` (updated by the executor's install only), `packages/contracts/src/environment.ts`, `packages/contracts/src/environment.test.ts`, `packages/contracts/fixtures/**` (regenerated by `check:contracts` only), `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs`, `packages/shared/src/testSupport.ts` - Must not touch: `apps/web/src/components/terminalReplyGuard.ts`, `packages/client-runtime/**`, `apps/server/src/terminal/**` (the separate #350 plan owns them), `apps/web/src/components/ChatView.tsx`, and every doc under `docs/` (Phase 9 owns doc edits) - Skills: `tdd` (each behavior change is red first), `vercel-react-best-practices` (required review of every change to the panel's effects and hooks), `mstack-diagnose` (R16 and R43 are decided by tests) - Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data); `UI.md` (the Fit notice wording and its status region); `docs/architecture/overview.md`, the terminal size ownership paragraphs around "Resize requests publish `resized { size }`" (owner, mirror and hand-over rules this phase must keep); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (R1 to R5, R16, R17, R41 to R43, D4, D12, D13) -- Checkpoint: From `apps/web`: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx src/components/ThreadTerminalPanel.test.tsx src/components/terminalSizeDiagnostics.test.ts src/components/terminalUnicode.xterm.test.ts src/components/terminalSizePolicy.test.ts src/components/terminalSizing.test.ts src/lib/terminalFont.test.ts` exits 0; From the repository root: `vp test run packages/contracts/src/environment.test.ts`, `vp run check:contracts` and `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exit 0; From the repository root: `vp check`, `vp run typecheck`, `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0. +- Checkpoint: From `apps/web`: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx src/components/ThreadTerminalPanel.test.tsx src/components/terminalSizeDiagnostics.test.ts src/components/terminalUnicode.xterm.test.ts src/components/terminalSizing.xterm.test.ts src/components/terminalSizePolicy.test.ts src/components/terminalSizing.test.ts src/lib/terminalFont.test.ts` exits 0; From the repository root: `vp test run packages/contracts/src/environment.test.ts`, `vp run check:contracts` and `cargo test -p bibcode-server --lib -- connect_descriptor_advertises_remote_update_support environment_descriptor_advertises_remote_update_control_and_support` exit 0; From the repository root: `vp check`, `vp run typecheck`, `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0. Every task edits `ThreadTerminalPanel.tsx` or its interaction test, so tasks run one after another in the listed order. The separate #350 plan edits the same file after this phase. @@ -27,34 +27,73 @@ The interaction test (`ThreadTerminalPanel.interactions.test.tsx`) replaces thes Other timers are real unless a test calls `vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] })`. Each task below says which of these it uses. +Every new async test waits on an explicit signal: a promise the test resolves, a write callback, or a captured timer handler. It also carries `{ timeout: 30_000 }`, a bound that only a hang reaches. No new test waits in a yield loop. + +Real boundaries and where each is proven: +- xterm caches its cell size in `CharSizeService`, and `FitAddon.proposeDimensions` reads it from `_renderService.dimensions.css.cell`. A repaint (`clearTextureAtlas`, `refresh`) measures nothing (`RenderService.clearTextureAtlas` only clears and refreshes). Setting an option to its current value fires nothing (`OptionsService`'s setter skips equal values). Tasks 1.1 and 1.2 cross this boundary in a new real-xterm test (`terminalSizing.xterm.test.ts`, real `Terminal` and real `FitAddon`). The interaction harness then proves the panel calls the measurement first. +- The browser's `FontFaceSet` firing `loadingdone` is a platform event. happy-dom has no font loading, so the tests dispatch the event on a stub. No automated step in this plan fires it from a real font load; this is reported as a residual risk in the phase report. +- The real WebGL addon and a real GPU context loss (Task 1.7) cannot run under happy-dom. The task proves the panel's own retry flag and warning; no step in this plan loses a real context. This is reported as a residual risk. +- Effect order (the creation effect commits the renderer epoch before the font effect re-runs) is React's. Tasks 1.1, 1.2 and 1.4 test it through the real `TerminalViewport`, never by calling the effect bodies. +- The refused Fit (Task 1.5) depends on the server's `TerminalResizeResult` outcome. The test feeds the typed outcome `claimNotRegistered` through the harness's fake RPC, as the existing Fit tests do. The server side of that answer belongs to the terminal manager's own tests and is not changed here. +- The capability crosses the server's descriptor (Rust tests) and the client's schema decode (`environment.test.ts`, the real `ExecutionEnvironmentCapabilities`), Task 1.6. +- Unicode 11 widths cross the real xterm and the real addon (Task 1.10). + Profiler coverage line for the final report: none applies. This phase adds no child process, no RPC method, no worktree removal, no binary mode, no shutdown step and no profiler event. The R4 record is a development-only in-memory ring, not a profiler record. R42 changes one existing `console.warn` in the browser, and that warning carries a failure stage and an error type name, never an error message or terminal content. ## Tasks ### Task 1.1: Repaint after a late font load -- Satisfies: R1. Given a rendered terminal with an unchanged mount size, when `document.fonts` dispatches `loadingdone`, then the terminal is re-measured and `clearTextureAtlas` and `refresh` each run once. An owner refits. A mirror keeps the server's columns and rows and sends no size claim. The owner, mirror and legacy assertions equal those of the existing device-pixel-ratio test. Given the terminal is unmounted, nothing runs. +- Satisfies: R1, in three scenarios: + - Given a rendered terminal with an unchanged mount size, when `document.fonts` dispatches `loadingdone`, then the terminal is re-measured and `clearTextureAtlas` and `refresh` each run once. An owner refits. A mirror keeps the server's columns and rows and sends no size claim. The owner, mirror and legacy assertions equal those of the existing device-pixel-ratio test. + - Given a real xterm with an unchanged mount size, when the font's glyph metrics change and `loadingdone` fires, then the cell dimensions are measured again before any size is proposed. + - Given the terminal is unmounted, nothing runs. - Depends on: none -- Files: modify `apps/web/src/components/ThreadTerminalPanel.tsx`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` -- Interfaces: consumes `scheduleResize`, the function inside the mount-observer effect (the effect with `new ResizeObserver(scheduleResize)`). Its existing callers are: +- Files: modify `apps/web/src/components/terminalSizing.ts`, `apps/web/src/components/ThreadTerminalPanel.tsx`; create `apps/web/src/components/terminalSizing.xterm.test.ts`; test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: produces, in `terminalSizing.ts`, `export function remeasureTerminalCharSize(terminal: Terminal): boolean`. It calls xterm 6.0.0's private `terminal._core._charSizeService.measure()`, feature-detected, and returns `false` when that member is missing. It carries the comment `shortcut: calls xterm 6.0.0's private CharSizeService.measure, feature-detected; remove when xterm exposes a public re-measure`, the pattern `syncXtermScrollArea` in `terminalFollowOutput.ts` already uses. + + `measure()` fires `onCharSizeChange` only when the size changed; `RenderService.handleCharSizeChanged` then updates `dimensions.css.cell` synchronously. So the fit addon's next `proposeDimensions` reads the new cell size. + + Alternatives rejected (**agent's call**): + - re-assigning `terminal.options.fontFamily` to its current value fires no option change (the `OptionsService` setter skips equal values), so it measures nothing; + - toggling `fontFamily` or `fontSize` to another value and back measures twice and clears the renderer twice. + + The panel's `loadingdone` listener is `handleFontLoadingDone`. It calls `remeasureTerminalCharSize(terminalRef.current)` when a terminal exists, then `scheduleResize()`. + + Also consumes `scheduleResize`, the function inside the mount-observer effect (the effect with `new ResizeObserver(scheduleResize)`). Its existing callers are: - the `ResizeObserver`; - the `CENTER_PANEL_RESIZE_END_EVENT` listener on the closest `[data-center-panel-workspace]`; - `handlePixelRatioChange`. `scheduleResize` never calls `fit` itself. It returns early while `isCenterPanelResizing(container)` is true; the drag-end event runs it again afterwards. It runs `rendererRef.current?.size("layout")` in a frame and then, 50 ms later, `terminal.clearTextureAtlas()` and `terminal.refresh(0, terminal.rows - 1)`. `size` is `handleSizeTrigger`: - With `sizeOwnership` false (legacy), `"layout"` calls `fitTerminalSafely(fitAddon)` and `requestTerminalResize`. - - With ownership, it only re-measures through `updateFittedDimensions` (`proposeTerminalDimensions`, which does not resize xterm). An owner then claims, and xterm is resized by `applySize` from the ordered `resized` signal. A mirror only updates its scale through `updateSizeNotice`. + - With ownership, it only recomputes the proposal through `updateFittedDimensions` (`proposeTerminalDimensions`, which does not resize xterm). An owner then claims, and xterm is resized by `applySize` from the ordered `resized` signal. A mirror only updates its scale through `updateSizeNotice`. - Produces a `loadingdone` listener on `document.fonts` that calls `scheduleResize`, removed in the same effect's cleanup. -- Skills: tdd (red-green for the new listener); vercel-react-best-practices (effect cleanup and dependency review) -- Read first: `ThreadTerminalPanel.tsx` (the mount-observer effect and `handleSizeTrigger`); the test "refits when the device pixel ratio changes under an unchanged mount" (its own `fit.mockImplementation`, the owner and mirror snapshots and its fake timers) + Re-measuring changes only the cell size, never the grid, so a mirror's columns and rows stay the server's; its scale follows the new proposal through `updateSizeNotice`. The listener is added in the mount-observer effect and removed in its cleanup. +- Skills: tdd (red-green for the measurement and the listener); vercel-react-best-practices (effect cleanup and dependency review) +- Read first: + - `ThreadTerminalPanel.tsx` (the mount-observer effect and `handleSizeTrigger`); `terminalFollowOutput.ts` (`syncXtermScrollArea`) and `terminalFollowOutput.xterm.test.ts` (the private-member pin); + - xterm 6.0.0 under `apps/web/node_modules/@xterm/xterm/src`: `browser/services/CharSizeService.ts` (`measure`, `DomMeasureStrategy`, which reads `offsetWidth / 32` and `offsetHeight` of `.xterm-char-measure-element`, and `TextMetricsMeasureStrategy`, which needs `OffscreenCanvas`); `browser/services/RenderService.ts` (`handleCharSizeChanged`, `clearTextureAtlas`); `common/services/OptionsService.ts` (the setter); + - `apps/web/node_modules/@xterm/addon-fit/src/FitAddon.ts` (`proposeDimensions` reads `_renderService.dimensions.css.cell` and the parent's computed `width` and `height`); + - the test "refits when the device pixel ratio changes under an unchanged mount" (its own `fit.mockImplementation`, the owner and mirror snapshots and its fake timers). - Tier: standard - TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"` exits 0 and runs four tests (three modes plus the unmount test). +- Done when: from `apps/web`, `vp test run --project unit src/components/terminalSizing.xterm.test.ts` exits 0 and runs two tests, and `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "font finishes loading"` exits 0 and runs four tests (three modes plus the unmount test). Steps -1. [ ] Next to "refits when the device pixel ratio changes under an unchanged mount", write `it.each(["legacy", "owner", "mirror"] as const)("re-measures and repaints when a font finishes loading under an unchanged mount (%s)")`. It is a new test using the real `TerminalViewport` and the file's fakes. Copy the pixel-ratio test's setup exactly: +1. [ ] Write `terminalSizing.xterm.test.ts` with `// @vitest-environment happy-dom` and no mocks: the real `@xterm/xterm` `Terminal` and the real `@xterm/addon-fit` `FitAddon`. Both tests carry `{ timeout: 30_000 }`. + - Stub `HTMLElement.prototype` `offsetWidth` and `offsetHeight`. For an element with class `xterm-char-measure-element`, return `cellWidth * 32` and `cellHeight` from test variables (start at 8 and 16); otherwise use the original getters. Restore them in `afterEach`. + - Mount into a host `div` with inline `width: 640px; height: 320px`, and load the fit addon. As a premise, assert that the host contains `.xterm-char-measure-element`: happy-dom has no `OffscreenCanvas`, so xterm uses its DOM strategy. Also assert that `proposeDimensions()` is defined. + - `it("re-measures a changed cell size under an unchanged mount before proposing dimensions")`: + 1. Read `before = fit.proposeDimensions()`. + 2. Set `cellWidth = 10`, standing for the font's new glyph metrics. + 3. Assert `fit.proposeDimensions()` still equals `before`. This is the cache the plain repaint cannot clear. + 4. Call `remeasureTerminalCharSize(terminal)` and assert it returns `true`. + 5. Assert `fit.proposeDimensions().cols` is less than `before.cols` and `.rows` equals `before.rows`, while the host's size is unchanged. + - `it("the installed xterm still has the private char-size measure the font repaint calls")`: assert `typeof terminal._core._charSizeService.measure === "function"`. +2. [ ] Run `vp test run --project unit src/components/terminalSizing.xterm.test.ts`. Expected: FAIL, `remeasureTerminalCharSize` is not exported. +3. [ ] Next to "refits when the device pixel ratio changes under an unchanged mount", write `it.each(["legacy", "owner", "mirror"] as const)("re-measures and repaints when a font finishes loading under an unchanged mount (%s)")`. It is a new test using the real `TerminalViewport` and the file's fakes. Copy the pixel-ratio test's setup exactly: - `fit.mockImplementation(() => { terminal.cols = proposed.cols; terminal.rows = proposed.rows; })`, so `fit` really resizes the grid as the real fit addon does; - the snapshot sized 80 × 24 with `sizeClaim` `null` for owner and `"other-window"` for mirror; - the owner's acknowledging `resized` with its claim read from `view.resizeSpy`. @@ -69,12 +108,16 @@ Steps - owner: a resize to 80 × 23 with its `sizeClaim` was sent, and after the `resized` event `terminal.rows` is 23; - mirror: `fit` not called, `resizeSpy` not called, and `terminal.rows` still 24. - In every mode, `clearTextureAtlas` ran once and `refresh` was called with `(0, terminal.rows - 1)`. Write `it("does nothing when a font finishes loading after unmount")`: mount, flush, `await unmount(view.mounted)`, clear the spies, dispatch `loadingdone`, flush, and assert that `fit`, `clearTextureAtlas`, `refresh` and `resizeSpy` were not called. -2. [ ] Run the Done-when command. Expected: FAIL in the three mode cases with `clearTextureAtlas` called 0 times. The mirror case must fail only on `clearTextureAtlas` and `refresh`, never on `fit`; a failure on `fit` means the setup differs from the pixel-ratio test. The unmount test passes. -3. [ ] In the mount-observer effect, add `document.fonts?.addEventListener("loadingdone", scheduleResize)` and the matching `removeEventListener` in its cleanup. Guard with `typeof document.fonts?.addEventListener === "function"`. Do not call `fitAddon.fit()` or `fitTerminalSafely` from the listener. -4. [ ] Run the Done-when command. Expected: PASS, 4 tests. -5. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand (`git stash` is not allowed; delete the two added lines) and rerun. Expected: FAIL. Restore the change and rerun. Expected: PASS. -6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. + In every mode, `clearTextureAtlas` ran once and `refresh` was called with `(0, terminal.rows - 1)`. + + The fake `Terminal` in `vi.mock("@xterm/xterm", …)` has no `_core`. Give the fake class the field `readonly _core = { _charSizeService: { measure: vi.fn() } }`, so every fake terminal has one; existing tests do not read it. The real `remeasureTerminalCharSize` then calls into it. In every mode, assert `measure` was called once and that its `mock.invocationCallOrder[0]` is lower than that of the first `proposeDimensions` call after the dispatch: the measurement comes before any size is proposed. For the mirror, also assert `terminal.cols` and `terminal.rows` are unchanged. + + Write `it("does nothing when a font finishes loading after unmount")`: mount, flush, `await unmount(view.mounted)`, clear the spies, dispatch `loadingdone`, flush, and assert that `measure`, `fit`, `clearTextureAtlas`, `refresh` and `resizeSpy` were not called. Both tests carry `{ timeout: 30_000 }`. +4. [ ] Run the second Done-when command. Expected: FAIL in the three mode cases, with `measure` and `clearTextureAtlas` called 0 times. The mirror case must not fail on `fit`; a failure on `fit` means the setup differs from the pixel-ratio test. The unmount test passes. +5. [ ] Implement `remeasureTerminalCharSize` in `terminalSizing.ts` as in Interfaces. In the mount-observer effect, add `handleFontLoadingDone`, register it with `document.fonts?.addEventListener("loadingdone", handleFontLoadingDone)` and remove it in the cleanup. Guard with `typeof document.fonts?.addEventListener === "function"`. Do not call `fitAddon.fit()` or `fitTerminalSafely` from the listener. +6. [ ] Run both Done-when commands. Expected: PASS, 2 and 4 tests. +7. [ ] Prove the tests can fail: revert this task's changes to `ThreadTerminalPanel.tsx` and `terminalSizing.ts` by hand (`git stash` is not allowed; delete the added lines) and rerun both commands. Expected: FAIL in both. Restore the changes and rerun. Expected: PASS. Then revert only the `remeasureTerminalCharSize(...)` call in `handleFontLoadingDone` and rerun the second command. Expected: FAIL on the measurement-order assertion. Restore it and rerun. Expected: PASS. +8. [ ] From `apps/web`, run `vp check src/components/terminalSizing.ts src/components/terminalSizing.xterm.test.ts src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.2: Repaint a renderer created after host configuration @@ -88,7 +131,9 @@ Steps - `resizeRendererGenerationRef`; - `canAttachTerminal` (`command === undefined || hasAuthoritativeHostConfig`), which gates the creation effect. - Font writers: the creation effect passes `fontFamily: readTerminalFontFamily()`, and the font effect assigns `terminal.options.fontFamily`. Produces a font effect whose dependency list is `[shouldRender, terminalFontFamily, rendererEpoch]`, with the early return `rendererEpoch !== rendererEpochRef.current` (the guard the WebGL effect already uses). + Font writers: the creation effect passes `fontFamily: readTerminalFontFamily()`, and the font effect assigns `terminal.options.fontFamily`. The effect's existing `.then` runs `clearTextureAtlas`, `size("layout")` and `refresh`, but it never re-measures. On a new renderer its `fontFamily` assignment repeats the value the constructor set, and xterm's `OptionsService` setter fires nothing for an equal value. On a family change, xterm measures at the assignment, before the font has loaded. Either way, the fit addon then reads stale cell dimensions. + + Produces a font effect whose dependency list is `[shouldRender, terminalFontFamily, rendererEpoch]`, with the early return `rendererEpoch !== rendererEpochRef.current` (the guard the WebGL effect already uses). Its `.then` calls `remeasureTerminalCharSize(terminal)` (Task 1.1) before `clearTextureAtlas`, `size("layout")` and `refresh`. The real-xterm proof that this helper refreshes the cached cell size is Task 1.1's `terminalSizing.xterm.test.ts`. - Skills: tdd (red-green for the new dependency); vercel-react-best-practices (effect dependency and stale-closure review) - Read first: `ThreadTerminalPanel.tsx` (the font effect, the creation effect and the WebGL effect's epoch guard); the tests "waits for authoritative host config before attaching Codex" and "issues one resize for the initial geometry and skips unchanged fits from either path" - Tier: standard @@ -97,13 +142,18 @@ Steps Steps -1. [ ] Write two new tests next to "waits for authoritative host config before attaching Codex", built like it (`testState.serverConfig = null`, a Codex `command`, the real `TerminalViewport`). In both, make `testState.fontLoad` return a promise whose `resolve` the test keeps. Expected: the change is in place, and the next command step checks it. - - `it("repaints a renderer created after host config once the bundled font loads")`: mount, clear `resizeSpy`, then deliver the config with `mounted.root.render(...)` inside `act`. Run `view.flushFrame()` and `view.flushMountFit()`. Resolve the font promise inside `act(async () => { resolve(); await Promise.resolve(); })`. Assert on the new fake terminal (`xtermState.terminals.at(-1)`): `clearTextureAtlas` once and `refresh(0, rows - 1)`. Assert `resizeSpy` was called exactly once. - - `it("ignores a host config font load that resolves after its renderer was disposed")`: deliver the config, then `await view.setVisible(false)` and wait for it to settle (the hide disposes the terminal; assert `terminal.dispose` was called). Then resolve the font promise inside `act`, and assert `clearTextureAtlas` and `refresh` were not called on that disposed terminal. -2. [ ] Run the first Done-when command. Expected: FAIL in the first test, `clearTextureAtlas` called 0 times. The second test passes. -3. [ ] Add `rendererEpoch` to the font effect's dependency list and return early when `rendererEpoch !== rendererEpochRef.current`. Keep the existing `cancelled`, generation and identity checks in the `.then` callback. -4. [ ] Run both Done-when commands. Expected: PASS. -5. [ ] Prove the test can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand (remove the dependency and the guard) and rerun the first command. Expected: FAIL. Restore it and rerun. Expected: PASS. +1. [ ] Write two new tests next to "waits for authoritative host config before attaching Codex", built like it (`testState.serverConfig = null`, a Codex `command`, the real `TerminalViewport`). In both, make `testState.fontLoad` return a promise whose `resolve` the test keeps. Both carry `{ timeout: 30_000 }` and wait only on that promise and the harness's frame and timer flushes. Expected: the change is in place, and the next command step checks it. + - `it("repaints a renderer created after host config once the bundled font loads")`: mount, clear `resizeSpy`, then deliver the config with `mounted.root.render(...)` inside `act`. Run `view.flushFrame()` and `view.flushMountFit()`. Resolve the font promise inside `act(async () => { resolve(); await Promise.resolve(); })`. Assert on the new fake terminal (`xtermState.terminals.at(-1)`): + - `_core._charSizeService.measure` was called once, before the first `proposeDimensions` call after the resolve (`mock.invocationCallOrder`); + - `clearTextureAtlas` was called once and `refresh` with `(0, rows - 1)`; + - `resizeSpy` was called exactly once. + - `it("ignores a host config font load that resolves after its renderer was disposed")`: deliver the config, then `await view.setVisible(false)` and wait for it to settle (the hide disposes the terminal; assert `terminal.dispose` was called). Then resolve the font promise inside `act`, and assert `measure`, `clearTextureAtlas` and `refresh` were not called on that disposed terminal. + + Also extend the existing `it.each([false, true])("updates the font without recreating the terminal (WebGL: %s)")` (it sets `{ mode: "custom", family: "Maple Mono" }` and asserts `clearTextureAtlas` once): assert `_core._charSizeService.measure` ran once, before its `fit` call. +2. [ ] Run the first Done-when command, and the same command with `-t "updates the font without recreating the terminal"`. Expected: FAIL. The first new test sees `measure` and `clearTextureAtlas` called 0 times; the font-change test sees `measure` called 0 times. The disposed test passes. +3. [ ] Add `rendererEpoch` to the font effect's dependency list and return early when `rendererEpoch !== rendererEpochRef.current`. Add the `remeasureTerminalCharSize(terminal)` call at the start of the `.then` work, after the existing `cancelled`, generation and identity checks. +4. [ ] Run both Done-when commands and the `-t "updates the font without recreating the terminal"` command. Expected: PASS. +5. [ ] Prove the tests can fail: revert this task's change to `ThreadTerminalPanel.tsx` by hand (the dependency, the guard and the call) and rerun the first command and the `-t "updates the font without recreating the terminal"` command. Expected: FAIL. Restore it and rerun. Expected: PASS. 6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. ### Task 1.3: Assert the repaint on a same-size return @@ -313,77 +363,67 @@ Steps ### Task 1.8: Decide whether a re-attach sends a transient size (R16) -- Satisfies: R16. Using R4's record, a test replays an attach followed by a font-metrics change and an Activity dock change. - - If a size request with different rows is sent and then reverted, the settle rule is fixed so that only the final size is requested. - - Otherwise the result is recorded and no code changes. +- Satisfies: R16. Using R4's record, a test replays an attach followed by a font-metrics change and an Activity dock change. It counts as a **defect** if a size request with different rows is sent and then reverted (for example `85x33` followed by `85x30`). No defect means only the final size is requested. The task ends at a logged verdict. - Depends on: 1.1, 1.4, 1.7 -- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; modify `apps/web/src/components/ThreadTerminalPanel.tsx` only in the defect branch +- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` - Interfaces: consumes `readTerminalSizeDiagnostics()`, `setTerminalSizeDiagnosticsEnabled(true)`, `clearTerminalSizeDiagnostics()`, `TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS` (100) and `TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS` (500). Also consumes the renderer's mount-settle state: - `mountSettleRemaining` (reads `mount.clientWidth` and `mount.clientHeight`); - `mountSizeChangedAt` and `mountUnsettledSince`; - `deferredClaimTimer` and `deferredTrigger`, written only inside `handleSizeTrigger` and the creation effect's cleanup. - Produces, only in the defect branch, a renderer method `noteGeometryInputChanged(): void`. It sets `mountSizeChangedAt = performance.now()` and `mountUnsettledSince ??= mountSizeChangedAt`, and is called from the Task 1.1 `loadingdone` listener before `scheduleResize`. -- Skills: mstack-diagnose (decide by test; the test is the deliverable); tdd (red-green in the defect branch) + Produces the test and a verdict line in `progress.md`; no product code. +- Skills: mstack-diagnose (decide by test; the test is the deliverable) - Read first: `ThreadTerminalPanel.tsx` (`mountSettleRemaining`, `handleSizeTrigger`, the deferred claim timer). In the describe "automatic claims wait for a settled mount (#348)": its `clientWidth`/`clientHeight` overrides (`mountSizes`), `resizeMount`, `settle`, `claimsOf` and the test "sends one claim at the final size after a mount passes through a few pixels". Also "mounts a correlated provider dock in the exact terminal viewport". - Tier: most capable -- TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"` exits 0, and the outcome (defect fixed, or no defect) is written in the phase report with the recorded request sequence. +- TDD: exempt (decide by test; a fix, if any, is its own task) +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "re-attach transient"` has been run, and `progress.md` holds the verdict with the recorded claim sequence. If the verdict is "no defect", the command exits 0. Steps -1. [ ] Inside the describe "automatic claims wait for a settled mount (#348)", write `it("sends only the final size after a re-attach, a font-metrics change and an Activity dock change (re-attach transient)")`. +1. [ ] Inside the describe "automatic claims wait for a settled mount (#348)", write `it("sends only the final size after a re-attach, a font-metrics change and an Activity dock change (re-attach transient)", { timeout: 30_000 })`. Harness decisions: - It uses that describe's real timers and its `settle()` helper (a real 140 ms wait), as the other tests there do. No fake timers. - The Activity dock is not mounted in this harness. Its only effect on sizing is a smaller mount box, so the dock change is reproduced by `resizeMount` with a smaller height. The real dock mounting is covered by "mounts a correlated provider dock in the exact terminal viewport". - - The font-metrics change is a new fit with more rows, delivered through `relayout` with the `document.fonts` stub from Task 1.1 dispatching `loadingdone` instead of the ResizeObserver. + - The font-metrics change is a new proposal with more rows plus the `document.fonts` stub from Task 1.1 dispatching `loadingdone`. The real measurement of a changed cell size is Task 1.1's real-xterm test. Steps of the test: 1. Stub `document.fonts`, enable and clear diagnostics, and set `hasFocus` to return `true`. 2. Open a window with `{ cols: 85, rows: 30 }` and `await settle()`. Record `before = claimsOf(owner).length`. - 3. Without awaiting `settle()`: set the proposal to `{ cols: 85, rows: 33 }` and dispatch `loadingdone` inside `act`, flushing frames. Then `resizeMount(owner, { width: 600, height: 400 }, { cols: 85, rows: 30 })` for the dock. + 3. Without awaiting `settle()`: set the proposal to `{ cols: 85, rows: 33 }` and dispatch `loadingdone` inside `act`, flushing frames. Then call `resizeMount(owner, { width: 600, height: 400 }, { cols: 85, rows: 30 })` for the dock. 4. `await settle()` twice. 5. Assert `claimsOf(owner).slice(before)` contains no row sequence that goes to 33 and back to 30. Assert its last entry, if any, is `"85x30"`. Assert the `request` rows in `readTerminalSizeDiagnostics()` agree. 6. Disable diagnostics in `finally`. -2. [ ] Run the Done-when command. Expected: PASS means no defect, and FAIL means the defect branch. - - Defect branch: FAIL with claims such as `["85x33", "85x30"]`. Go to step 3. - - No-defect branch: PASS. Prove the test can see a transient: temporarily make `mountSettleRemaining` return `0` (it is the only gate that holds an automatic claim, so the font step's 33 rows are then claimed at once, before the dock's 30) and rerun. Expected: FAIL. Restore it and rerun. Expected: PASS. Then log "R16: no transient; claims …" with the recorded sequence in the phase report and stop. -3. [ ] Defect branch: add `noteGeometryInputChanged()` as in Interfaces, and call it from the `loadingdone` listener before `scheduleResize`. A font change then restarts the 100 ms quiet period like a mount change does. The dock change already resizes the mount, so it is already covered. -4. [ ] Run the Done-when command. Expected: PASS. Run `-t "sends one claim at the final size"` and `-t "does not claim the sizing minimum at the maximum wait"`. Expected: PASS. -5. [ ] Defect branch only: revert this task's change to `ThreadTerminalPanel.tsx` by hand and rerun. Expected: FAIL. Restore it and rerun. Expected: PASS. -6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. +2. [ ] Prove the test can see a transient before trusting a pass. Temporarily make `mountSettleRemaining` return `0` and rerun the Done-when command. It is the only gate that holds an automatic claim, so the font step's 33 rows are then claimed at once, before the dock's 30. Expected: FAIL with a `33` then `30` sequence. Restore it by hand. +3. [ ] Run the Done-when command. Expected: PASS for verdict "no defect", or FAIL with claims such as `["85x33", "85x30"]` for verdict "defect". + - Verdict "no defect": the test stays as proof. Log "R16: no transient; claims …" with the recorded sequence in `progress.md`. + - Verdict "defect": open a new task in this phase for the fix. Its first step is this failing test. It names the first wrong decision, read from the diagnostics record: the trigger, fitted rows and `mountSettleRemaining` result of the claim that sent 33 rows. Log it in `progress.md` as a Ruling. This task changes no product code. +4. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.interactions.test.tsx`. Expected: no errors. ### Task 1.9: Decide whether the grid is taller than its mount before the first snapshot (R43) -- Satisfies: R43. A test records rows and mount height between mount and the first snapshot after a reload. - - If rows exceed the fitted rows, the terminal is fitted before it is shown. - - Otherwise the result is recorded. +- Satisfies: R43. A test records rows and mount height between mount and the first snapshot after a reload. It counts as a **defect** if the grid's rows exceed the rows fitted to the mount before the first snapshot arrives. The task ends at a logged verdict. - Depends on: 1.8 -- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`; modify `apps/web/src/components/ThreadTerminalPanel.tsx` only in the defect branch -- Interfaces: consumes `terminalHostHasArea(host: Element): boolean` and `proposeTerminalDimensions(fit: Pick, host?: Element): TerminalDimensions | null` from `terminalSizing.ts`, and `fitTerminalSafely(fitAddon: FitAddon): boolean`. Its two callers are the creation effect right after `terminal.open(mount)`, and the legacy branch of `handleSizeTrigger`. With ownership, `handleSizeTrigger` never fits locally. - - Produces, only in the defect branch, a renderer flag `fittedBeforeFirstReset: boolean`, set to `true` by the reset branch of `render`. While it is `false`, the first `handleSizeTrigger("layout")` on a mount where `terminalHostHasArea(mount)` holds calls `fitTerminalSafely(fitAddon)` before any claim logic, in both ownership modes, and then sets the flag. -- Skills: mstack-diagnose (decide by test; the test is the deliverable); tdd (red-green in the defect branch) +- Files: test `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx` +- Interfaces: consumes `terminalHostHasArea(host: Element): boolean` and `proposeTerminalDimensions(fit: Pick, host?: Element): TerminalDimensions | null` from `terminalSizing.ts`, and `fitTerminalSafely(fitAddon: FitAddon): boolean`. Its two callers are the creation effect right after `terminal.open(mount)`, and the legacy branch of `handleSizeTrigger`. With ownership, `handleSizeTrigger` never fits locally. Produces the test and a verdict line in `progress.md`; no product code. +- Skills: mstack-diagnose (decide by test; the test is the deliverable) - Read first: the creation effect from `new Terminal({` to the 30 ms `fitTimer`. In the test file: the fit fake (`xtermState.fitAppliesProposal`), and the tests "sends keystrokes through the input scheduler before the first snapshot" (the `publishRuntime: false` mount) and "attaches without dimensions when the mount is created with no area". - Tier: most capable -- TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "keeps the grid within its mount"` exits 0 for both cases, and the outcome is written in the phase report with the recorded rows and mount height. +- TDD: exempt (decide by test; a fix, if any, is its own task) +- Done when: from `apps/web`, `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx -t "keeps the grid within its mount"` has been run for both cases, and `progress.md` holds the verdict with the recorded rows and mount height. If the verdict is "no defect", the command exits 0. Steps -1. [ ] Next to "sends keystrokes through the input scheduler before the first snapshot", write `it.each([false, true])("keeps the grid within its mount before the first snapshot after a reload (ownership %s)")` on the real viewport. Steps of the test: Expected: the change is in place, and the next command step checks it. +1. [ ] Next to "sends keystrokes through the input scheduler before the first snapshot", write `it.each([false, true])("keeps the grid within its mount before the first snapshot after a reload (ownership %s)", { timeout: 30_000 })` on the real viewport. Steps of the test: Expected: the change is in place, and the next command step checks it. 1. Set `xtermState.fitAppliesProposal = true`, so the fit fake really resizes as the real addon does. Set `xtermState.proposedDimensions = { cols: 80, rows: 20 }`, set the ownership capability per case, and mount with `publishRuntime: false`. The fake starts at 24 rows. 2. Run `view.flushFrame()` and `view.flushMountFit()` (the 30 ms timer). Time is otherwise not advanced. 3. Before any snapshot is ingested, read `terminal.rows`, `mount.clientHeight` and `proposeTerminalDimensions(fitAddon, mount)`. 4. Assert `terminal.rows <= 20`. -2. [ ] Run the Done-when command. Expected: PASS means no defect, and FAIL means the defect branch. - - Defect branch: FAIL with `24 > 20` in one or both cases. Go to step 3. - - No-defect branch: PASS. Prove the test can see a tall grid: temporarily delete the `fitTerminalSafely(fitAddon)` call after `terminal.open(mount)` and rerun. Expected: FAIL in the ownership case (it has no other fit before a snapshot, per Interfaces). The legacy case may still pass through its 30 ms layout fit. Restore it and rerun. Expected: PASS. Then log "R43: no tall grid; rows … mount … px" in the phase report and stop. -3. [ ] Defect branch: add `fittedBeforeFirstReset` as in Interfaces. -4. [ ] Run the Done-when command. Expected: PASS. Run `-t "attaches without dimensions when the mount is created with no area"` and `-t "issues one resize for the initial geometry"`. Expected: PASS. -5. [ ] Defect branch only: revert this task's change to `ThreadTerminalPanel.tsx` by hand and rerun. Expected: FAIL. Restore it and rerun. Expected: PASS. -6. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.tsx src/components/ThreadTerminalPanel.interactions.test.tsx`, then `vp run typecheck`. Expected: no errors. +2. [ ] Prove the test can see a tall grid before trusting a pass. Temporarily delete the `fitTerminalSafely(fitAddon)` call after `terminal.open(mount)` and rerun the Done-when command. Expected: FAIL in the ownership case, which has no other fit before a snapshot (per Interfaces). The legacy case may still pass through its 30 ms layout fit. Restore it by hand. +3. [ ] Run the Done-when command. Expected: PASS for verdict "no defect", or FAIL with `24 > 20` in one or both cases for verdict "defect". + - Verdict "no defect": the test stays as proof. Log "R43: no tall grid; rows … mount … px" in `progress.md`. + - Verdict "defect": open a new task in this phase for the fix. Its first step is this failing test. It names the first wrong decision: which fit was skipped or ran before the mount had its size, and in which ownership mode. Log it in `progress.md` as a Ruling. This task changes no product code. +4. [ ] From `apps/web`, run `vp check src/components/ThreadTerminalPanel.interactions.test.tsx`. Expected: no errors. ### Task 1.10: Count columns by Unicode 11 in every terminal diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md index 034726275..4933d5467 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-02-git-and-pull-request-lookups.md @@ -9,7 +9,7 @@ - Read first: `AGENTS.md` (completion rules, profiler coverage, sensitive data); `docs/architecture/overview.md` section Profiler (what the stop sequence and `tracing` records must keep covering); `docs/plans/2026-11-13-issue-batch-348-358/spec.md` (D5 to D8 are binding); `docs/plans/2026-11-09-issue-batch-340-345-290/spec.md` requirements R15 to R17 (lock ownership rule the comments restate) - Checkpoint: `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: sign_in:: pr_row_ a_failed_lookup index_lock_cleanup_ quiesce_in_` exits 0 and every listed filter runs at least one test; then `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings` exit 0 -Commands use the pinned toolchain (1.98.1) first on PATH, with `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`. Task order matters: 2.4, 2.5 and 2.6 all edit `open_pull_request_lookups.rs`, and 2.6 to 2.8 all edit `git_vcs.rs` and `broadcaster.rs`, so they run one after another. +Commands use the pinned toolchain (1.98.1) first on PATH, with `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=line-tables-only CARGO_BUILD_JOBS=3`. Task order matters: 2.4, 2.5 and 2.6 all edit `open_pull_request_lookups.rs`, and 2.6 to 2.8 all edit `git_vcs.rs`, and 2.6 and 2.7 `broadcaster.rs`, so they run one after another. Profiler coverage line for the final report: R26 adds a quiesce step before `git-writes` and before the profiler teardown (the step is timed by the existing `steps.mark` mechanism, so a slow step reaches `server.stop` through `slowSteps`); R27 adds one `tracing::warn!` with no URL, path or branch; nothing else in this phase spawns a process, adds an RPC method, removes a worktree or emits an event. @@ -68,7 +68,7 @@ Steps Steps 1. [ ] Settle the input question first and write the finding into the doc comment of `has_missing_username_refusal`. Read both callers. Finding from the code read for this plan: neither joins lines. `operations.rs` passes the raw `stderr` of the last Git output. `git_vcs.rs` passes the wire `detail`, which comes from `actionable_git_failure` and is the redacted, trimmed stderr, still multi-line. The `{detail} {note}` join in `git_vcs.rs` is built after the detector runs and is not an input. Git progress output separates updates with a carriage return, so the fatal line can follow a progress update after `\r` and not after `\n`. Re-read both files; if either now joins lines, add that separator to the split in step 4. -2. [ ] Write the failing test `the_missing_username_refusal_needs_a_fatal_line` in `apps/server/src/git/sign_in.rs` asserting: (a) `has_missing_username_refusal("fix: could not read Username and terminal prompts disabled")` is false; (b) a hook output line `hook: could not read username ... terminal prompts disabled` inside a multi-line text is false; (c) a text whose first line is `Writing objects: 50%\rfatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled` is true; (d) `" FATAL: Could not read Username for 'https://gitlab.example.test': Terminal Prompts Disabled\r\n"` is true; (e) a `fatal:` line holding only one phrase is false; (f) the existing `MISSING_USERNAME` constant still passes. +2. [ ] Write the failing test `the_missing_username_refusal_needs_a_fatal_line` in `apps/server/src/git/sign_in.rs` asserting: (a) `has_missing_username_refusal("fix: could not read Username and terminal prompts disabled")` is false; (b) a hook output line `hook: could not read username ... terminal prompts disabled` inside a multi-line text is false; (c) a text whose first line is `Writing objects: 50%\rfatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled` is true; (d) `" FATAL: Could not read Username for 'https://gitlab.example.test': Terminal Prompts Disabled\r\n"` is true; (e) a `fatal:` line holding only one phrase is false; (f) the existing `MISSING_USERNAME` constant still passes. Boundary: both callers reach the detector only through `ExecutionPlace::push_guidance`, so add `push_guidance_ignores_both_phrases_outside_a_fatal_line` in the same module: a remote `ExecutionPlace { remote: true, server_name: None }` returns `None` for the hook text of (b) and `Some(..)` for the `fatal:` line of (c). 3. [ ] Run `cargo test -p bibcode-server --lib sign_in::tests::the_missing_username_refusal_needs_a_fatal_line -j 2`. Expected: FAIL on assertion (a) or (b) (the old detector accepts them). 4. [ ] Implement in `sign_in.rs`: `detail.split(['\n', '\r']).any(|line| { let line = line.trim_start().to_ascii_lowercase(); line.starts_with("fatal:") && line.contains("could not read username") && line.contains("terminal prompts disabled") })`. Keep `#[must_use]` and the signature. 5. [ ] Run `cargo test -p bibcode-server --lib sign_in:: -j 2`. Expected: PASS, including `detects_the_missing_username_refusal_only_with_prompts_disabled` and the push-guidance tests in `git/manager/operations/sign_in_tests.rs` run by `cargo test -p bibcode-server --lib sign_in_tests -j 2`. @@ -77,49 +77,54 @@ Steps ### Task 2.4: Wait for running lookups at quiesce -- Satisfies: R26: quiesce waits for every running lookup before the profiler teardown, whether or not its cache entry still exists; a lookup admitted after shutdown ends at once as cancelled. +- Satisfies: R26: quiesce waits for every running lookup before the profiler teardown, whether or not its cache entry still exists; a lookup admitted after shutdown ends at once as cancelled; shutdown cannot finish while an admission sits between its closed check and its task registration. - Depends on: none -- Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/source_control/pull_request.rs`; modify `apps/server/src/production/git_vcs.rs`; modify `apps/server/src/production/runtime.rs`; test `apps/server/src/source_control/open_pull_request_lookups.rs` and `apps/server/src/production/git_vcs.rs` +- Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/source_control/pull_request.rs`; modify `apps/server/src/production/git_vcs.rs`; modify `apps/server/src/production/runtime.rs`; test `apps/server/src/source_control/open_pull_request_lookups.rs`, `apps/server/src/production/git_vcs.rs` and `apps/server/src/production/runtime.rs` +- Boundaries crossed in production: the runtime's `quiesce_for_update` calls the lookups' shutdown (the real quiesce path, covered by the runtime test in step 8); the runner kills a forge child by force inside the flight task (covered by the real-process test in step 7); the cache hands out flights to many callers (covered by the unit tests of step 3) - Interfaces: - - consumes `OpenPullRequestLookups::admit` (the bare `tokio::spawn` of the shared lookup and the `flight_cancellation = CancellationToken::new()`), `FlightLease` (its `Drop` cancels the flight token), `Slot::InFlight { id, lease, result }`, `invalidate` (removes entries, in-flight ones included, so the flight keeps running with no entry), `make_room` and the capacity check (`state.entries.len() < state.capacity`; when full the flight runs with no entry), `OpenPullRequestLookups::resolve(self: &Arc, key: LookupKey, canonical_cwd: PathBuf, cancellation: &CancellationToken, load: F) -> Option`, `LookupResult = Result, SourceControlProviderError>`, `tokio_util::task::TaskTracker` - - produces a root `shutdown: CancellationToken` and a `tasks: TaskTracker` as fields of `OpenPullRequestLookups` (outside the `Mutex`). Every flight token is created as `self.shutdown.child_token()` in `admit`, before and independent of whether an entry is inserted, so shutdown reaches an invalidated flight and a flight started with no cache room. The lease still cancels only its own child token - - produces `pub(crate) async fn shutdown(&self)` on `OpenPullRequestLookups`: cancels `shutdown`, calls `tasks.close()`, then `tasks.wait().await`; safe to call twice - - the cancelled result is the existing type `Option` with the value `None`: `resolve` returns `None` when `shutdown` is cancelled (a new first `biased` arm `() = self.shutdown.cancelled() => None` in its `select!`, and `Admission::Closed` from `admit` once `shutdown.is_cancelled()`, without calling `load`). `PullRequestService::resolve_current_optional_shared` already maps `None` to its "The pull-request lookup was cancelled." `SourceControlProviderError`. A flight that ends after shutdown still sends the loader's own outcome on its watch channel, but no waiter reads it - - R27 (Task 2.5) never stores a cancelled flight's outcome: `settle` receives `cancelled = flight_cancellation.is_cancelled()`, which is true after a lease drop and after shutdown (a child token is cancelled with its parent) + - consumes `OpenPullRequestLookups::admit` (the bare `tokio::spawn` of the shared lookup and the `flight_cancellation = CancellationToken::new()`), `FlightLease` (its `Drop` cancels the flight token), `Slot::InFlight { id, lease, result }`, `invalidate` (removes entries, in-flight ones included, so the flight keeps running with no entry), `make_room` and the capacity check (`state.entries.len() < state.capacity`; when full the flight runs with no entry), `OpenPullRequestLookups::resolve(self: &Arc, key: LookupKey, canonical_cwd: PathBuf, cancellation: &CancellationToken, load: F) -> Option`, `LookupResult = Result, SourceControlProviderError>`, `tokio_util::task::TaskTracker` with its `token()`, `close()` and `wait()` + - produces `shutdown: CancellationToken` and `tasks: TaskTracker` fields of `OpenPullRequestLookups`, and `closed: bool` in `State`. Admission and closing are serialized by the `State` lock, copying `GitWriteRegistry::track` and `close_admission` in `git/process.rs`, which take a tracker token under the admission lock. In `admit`, under the `State` lock: if `closed`, return `Admission::Closed`; otherwise take `let task = self.tasks.token()` and `let flight = self.shutdown.child_token()` (independent of whether an entry is inserted, so shutdown reaches an invalidated flight and a flight with no cache room). The flight future is `tokio::spawn(async move { let _task = task; .. })`, so the token moves into the task and is registered before the lock is released. The lease still cancels only its own child token + - produces `pub(crate) async fn shutdown(&self)`: under the `State` lock set `closed = true`, call `self.shutdown.cancel()` and `self.tasks.close()`; release the lock; then `self.tasks.wait().await`. Any admission either finished registering before the lock was taken (so `wait` covers it) or sees `closed`. Safe to call twice + - produces a `#[cfg(test)] admission_gate: Mutex>>` field, with `struct AdmissionGate { reached: std::sync::mpsc::SyncSender<()>, release: std::sync::Mutex> }`, called in `admit` after the lock is released and before `tokio::spawn`: it sends on `reached`, then blocks on `release`. It is the only way to hold an admission in that window + - the cancelled result is the existing type `Option` with the value `None`: `resolve` returns `None` when `shutdown` is cancelled (a first `biased` arm `() = self.shutdown.cancelled() => None` in its `select!`, and `Admission::Closed` from `admit` without calling `load`). `PullRequestService::resolve_current_optional_shared` already maps `None` to its "The pull-request lookup was cancelled." `SourceControlProviderError`. A flight that ends after shutdown still sends the loader's outcome on its watch channel, but no waiter reads it + - R27 (Task 2.5) never stores a cancelled flight's outcome: `settle` receives `cancelled = flight.is_cancelled()`, true after a lease drop and after shutdown (a child token is cancelled with its parent) - produces `PullRequestService::shutdown_open_pull_request_lookups(&self)` (`pub async fn`) calling the above - produces `GitVcsRpcServices::pull_requests(&self) -> PullRequestService` (`pub(crate)`, a clone; the runtime calls it after every builder, because `with_hosting_executable_dir_for_integration_test` replaces the service) - - every user of the shared cache, found with `rg "resolve_current_optional_shared|open_lookups"`: `lookup_open_pull_request` in `git_vcs.rs` (called from `vcs.refreshStatus`, its late callback, and the status stream's `start_lookup`); the `GitStatusSummaryService` holds a clone of the same `PullRequestService` and shares the `Arc`; `PullRequestService::invalidate_open_pull_request_lookups` (callers: `observe_local_changes`, the local-change closure near the `handle.spawn` site in `git_vcs.rs`, and `create_with_options` in `pull_request.rs`) stays valid after shutdown. `GitManagerRpcServices` builds its own `PullRequestService::default()` and never calls the shared lookup, so it needs no shutdown + - every user of the shared cache, found with `rg "resolve_current_optional_shared|open_lookups"`: `lookup_open_pull_request` in `git_vcs.rs` (called from `vcs.refreshStatus`, its late callback, and the status stream's `start_lookup`); `GitStatusSummaryService` holds a clone of the same `PullRequestService` and shares the `Arc`; `invalidate_open_pull_request_lookups` (callers: `observe_local_changes`, the local-change closure near the `handle.spawn` site in `git_vcs.rs`, and `create_with_options` in `pull_request.rs`) stays valid after shutdown. `GitManagerRpcServices` builds its own `PullRequestService::default()` and never calls the shared lookup, so it needs no shutdown - produces the quiesce step name `pull-request-lookups`, marked with `steps.mark("pull-request-lookups")` directly after `steps.mark("status-broadcaster")` and before `worktree-catalog-operations`, so it runs before `git-writes` and before `profiler-end-session`. Phase 9 copies this exact name into `events.md` (position: after `status-broadcaster`) - Skills: `tdd` (new shutdown behavior), `bibcode-profiler` (stop sequence and teardown order must stay covered) -- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (module doc, `admit`, `settle`, `invalidate`, `make_room`, the `Loader` test helper whose gated mode returns `Err(failure())` at once on cancellation); `apps/server/src/production/runtime.rs` (`quiesce_for_update` from `mcp_control` to `profiler-end-session`); `apps/server/src/source_control/pull_request.rs` (`resolve_current_optional_shared`, `run_provider_os_with_allowed_exit_codes`); `apps/server/src/production/git_vcs.rs` (`Fixture`, `FAKE_GH`, `a_forge_cli_is_running`, test `a_lookup_past_the_refresh_bound_stops_at_quiesce`) +- Read first: `apps/server/src/source_control/open_pull_request_lookups.rs` (module doc, `admit`, `settle`, `invalidate`, `make_room`, the `Loader` test helper whose gated mode returns `Err(failure())` at once on cancellation); `apps/server/src/git/process.rs` (`GitWriteRegistry::track`, `close_admission`, `stop_all_and_wait`: the pattern to copy); `apps/server/src/production/runtime.rs` (`quiesce_for_update`; test helper `start_quiesce_runtime` and test `index_lock_cleanup_quiesce_stops_a_running_git_write`, which drive a real `ProductionRuntime`); `apps/server/src/source_control/pull_request.rs` (`resolve_current_optional_shared`, `run_provider_os_with_allowed_exit_codes`, `ResolvePullRequestInput`); `apps/server/src/production/git_vcs.rs` (`Fixture`, `FAKE_GH`, `a_forge_cli_is_running`, test `a_lookup_past_the_refresh_bound_stops_at_quiesce`) - Tier: most capable - TDD: red-green -- Done when: `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_` exits 0 with five tests passed (`shutdown_waits_for_a_lookup_that_ends_late`, `shutdown_stops_an_invalidated_flight`, `shutdown_stops_a_flight_that_had_no_cache_room`, `shutdown_ends_a_retained_waiter_as_cancelled`, `shutdown_refuses_a_new_lookup`), `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::pull_request_lookup_shutdown_` exits 0 with one test passed, and the order check in step 10 prints the four names in order +- Done when: `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_ production::git_vcs::tests::pull_request_lookup_shutdown_ production::runtime::tests::quiesce_in_stops_a_running_pull_request_lookup` exits 0 with six, one and one tests passed, and the order check in step 10 prints the four names in order Steps 1. [ ] Settle where the `gh` child is reaped. Finding from the code read for this plan: the flight future calls `PullRequestService::resolve_current_optional`, which awaits `self.runner.run(ProcessRequest { .. }, cancellation)` in `run_provider_os_with_allowed_exit_codes`. That runner stops a non-Git child by force (stop policy `Force` in `git/process.rs`), so the child dies at the cancel and is reaped inside that awaited call, in the detached flight task. Joining the task is therefore enough. Record this in the doc comment of `shutdown`. Do not use a `TERM` trap to model a slow exit: it has no effect here. -2. [ ] Extend the `Loader` helper in the `tests` module: a field `cleanup: Duration` (default zero) and an `ended: Arc`; in gated mode, when the flight token is cancelled the loader does `tokio::time::sleep(self.cleanup).await`, increments `ended`, and returns `Err(failure())`. This is the seam: `load` is the closure the service hands the cache, so the delayed cleanup is injected without a real process. Add `#[cfg(test)] fn entry_count_for_test(&self) -> usize` on `OpenPullRequestLookups`. -3. [ ] Write the failing tests in `open_pull_request_lookups.rs`, each `#[tokio::test(start_paused = true)]` (the 200 ms is virtual time, advanced by the runtime when every task is idle): Expected: the change is in place, and the next command step checks it. +2. [ ] Extend the `Loader` helper in the `tests` module: a field `cleanup: Duration` (default zero) and an `ended: Arc`; in gated mode, when the flight token is cancelled the loader does `tokio::time::sleep(self.cleanup).await`, increments `ended`, and returns `Err(failure())`. The delayed cleanup is injected through `load`, the closure the service hands the cache, so no real process is needed at this level. Add `#[cfg(test)] fn entry_count_for_test(&self) -> usize` on `OpenPullRequestLookups`. +3. [ ] Write the failing tests in `open_pull_request_lookups.rs`. The first five are `#[tokio::test(start_paused = true)]` (the 200 ms is virtual time, advanced by the runtime when every task is idle); every wait on a spawned task is wrapped in `tokio::time::timeout(Duration::from_secs(30), ..)`: Expected: the change is in place, and the next command step checks it. - `shutdown_waits_for_a_lookup_that_ends_late`: a gated loader with `cleanup` 200 ms; spawn `resolve`, wait for the run, record `start = tokio::time::Instant::now()`, `lookups.shutdown().await`; assert `ended == 1` and `start.elapsed() >= 200 ms`. - - `shutdown_stops_an_invalidated_flight`: spawn `resolve` with the same loader, wait for the run, call `lookups.invalidate(path)` (the entry is gone), then `shutdown`; assert the loader saw its cancellation (`cancelled == 1`) and `ended == 1`. + - `shutdown_stops_an_invalidated_flight`: spawn `resolve` with the same loader, wait for the run, `lookups.invalidate(path)` (the entry is gone), then `shutdown`; assert `cancelled == 1` and `ended == 1`. - `shutdown_stops_a_flight_that_had_no_cache_room`: `OpenPullRequestLookups::with_limits(OPEN_PULL_REQUEST_LOOKUP_TTL, 0)` so no entry is stored; spawn `resolve`, wait for the run, `shutdown`; assert `cancelled == 1`, `ended == 1` and `entry_count_for_test() == 0`. - `shutdown_ends_a_retained_waiter_as_cancelled`: the waiter's own cancellation token is never cancelled and it still holds its lease when `shutdown` is called; assert the waiter's `resolve` returns `None` and `shutdown` returned. - `shutdown_refuses_a_new_lookup`: after `shutdown`, `resolve` returns `None` and `loader.runs() == 0`; a second `shutdown().await` returns at once. -4. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_`. Expected: FAIL to compile (`no method named shutdown`). Add `pub(crate) async fn shutdown(&self) {}` and `entry_count_for_test`, run again. Expected: five FAILs (`ended` is 0, `resolve` returns `Some(..)`). -5. [ ] Implement in `open_pull_request_lookups.rs` as listed in Interfaces. The spawn becomes `self.tasks.spawn(..)` and covers the no-room path too. `settle` is untouched here; Task 2.5 adds the `cancelled` parameter. + - `shutdown_cannot_finish_between_an_admissions_check_and_its_spawn`: `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]`, real clock. Install an `AdmissionGate`; spawn task A that calls `resolve` (its `admit` blocks in the gate, on a worker thread, after it registered its tracker token); receive on `reached` with `recv_timeout(Duration::from_secs(30))`; spawn task B that calls `lookups.shutdown()`; `tokio::time::timeout(Duration::from_millis(200), &mut b)` must time out (B is still waiting for A's registered token); send on `release`; B then finishes within a 30 s timeout; assert the loader ran once, saw its cancellation (`cancelled == 1`) and `ended == 1`, and A's `resolve` returned `None`. +4. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_`. Expected: FAIL to compile (`no method named shutdown`). Add `pub(crate) async fn shutdown(&self) {}`, the gate type and `entry_count_for_test` as stubs, run again. Expected: six FAILs (`ended` is 0, `resolve` returns `Some(..)`, B finishes before the release). +5. [ ] Implement in `open_pull_request_lookups.rs` as listed in Interfaces. `settle` is untouched here; Task 2.5 adds the `cancelled` parameter. 6. [ ] Add `PullRequestService::shutdown_open_pull_request_lookups` in `pull_request.rs` and `GitVcsRpcServices::pull_requests` in `git_vcs.rs`. -7. [ ] Write the failing wiring test `pull_request_lookup_shutdown_reaps_the_forge_cli` in the `tests` module of `git_vcs.rs` (Unix-only like its neighbors): `Fixture::new("pr-lookup-shutdown")`, `fixture.set("slow", "30")`, start a lookup with `fixture.refresh()` under `tokio::time::timeout(Duration::from_secs(5), ..)` so it returns at its bound while the fake `gh` keeps running, then `fixture.services.pull_requests().shutdown_open_pull_request_lookups().await`; immediately assert `!fixture.a_forge_cli_is_running()` (no polling: shutdown returned only after the reap). Run `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::pull_request_lookup_shutdown_`. Expected before step 6 is done: FAIL to compile; after steps 5 and 6: PASS. Expected: the change is in place, and the next command step checks it. -8. [ ] In `runtime.rs` add a field `pull_requests: crate::source_control::PullRequestService` to the runtime struct, filled from `git_vcs.pull_requests()` after the last builder call, and in `quiesce_for_update`, after `self.status_broadcaster.shutdown().await; steps.mark("status-broadcaster");`, add `self.pull_requests.shutdown_open_pull_request_lookups().await; steps.mark("pull-request-lookups");`. The order after the status broadcaster is deliberate: streams that hold leases stop first. -9. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: quiesce_in_ production::git_vcs::tests::a_lookup_past_the_refresh_bound_stops_at_quiesce`. Expected: PASS (all three filters match tests). +7. [ ] Write the real-process test `pull_request_lookup_shutdown_reaps_the_forge_cli` in the `tests` module of `git_vcs.rs` (Unix-only like its neighbors), with a subscriber so the late lookup is retained: `Fixture::new("pr-lookup-shutdown")`, `let mut subscriber = fixture.subscribe(&cancellation)`, wait for the first lookup (`wait_for_lookups(&fixture, "main", 1)`), `subscriber.drain(Duration::from_millis(300))`, then `fixture.set("slow", "30")`, `fixture.forget_lookups()` and `fixture.refresh()` under `tokio::time::timeout(Duration::from_secs(5), ..)` (it answers at its bound while `spawn_for_subscribers` keeps the lookup for the subscriber). Wait until the second lookup is recorded, assert `fixture.a_forge_cli_is_running()` is true, then `fixture.services.pull_requests().shutdown_open_pull_request_lookups().await`, and assert `!fixture.a_forge_cli_is_running()` immediately with no polling. Run `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::pull_request_lookup_shutdown_`. Expected before step 6 is done: FAIL to compile; after steps 5 and 6: PASS. Expected: the change is in place, and the next command step checks it. +8. [ ] Write the runtime-level test `quiesce_in_stops_a_running_pull_request_lookup` in the `tests` module of `production/runtime.rs` (Unix-only, `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]`), through the real quiesce path. Build the runtime like `start_quiesce_runtime` but with `ServerConfig::..with_hosting_executable_dir_for_integration_test(bin)`, where `bin/gh` is a script that writes its pid to a file, then `exec sleep 30`. Add a helper parameter or a sibling `start_quiesce_runtime_with(config)` so the existing helper is unchanged. Create a repository whose `origin` is `https://github.com/acme/repo.git`. Spawn a task that calls `runtime.pull_requests.resolve_current_optional_shared(ResolvePullRequestInput { cwd, provider: ProviderKind::Github, reference: "main".into() }, &CancellationToken::new())` and keeps its lease. Wait for the pid file with a bounded wait (30 s guard, the file is the signal), assert the pid is alive (`kill -0`), call `runtime.quiesce_for_update().await` and assert it is `Ok`, then assert the pid is gone with no polling and that the spawned task's result is the cancelled error. Run `cargo test -p bibcode-server --lib -j 2 -- production::runtime::tests::quiesce_in_stops_a_running_pull_request_lookup`. Expected before step 9: FAIL (the child is still alive after quiesce). Expected: the change is in place, and the next command step checks it. +9. [ ] In `runtime.rs` add a field `pull_requests: crate::source_control::PullRequestService` to the runtime struct, filled from `git_vcs.pull_requests()` after the last builder call, and in `quiesce_for_update`, after `self.status_broadcaster.shutdown().await; steps.mark("status-broadcaster");`, add `self.pull_requests.shutdown_open_pull_request_lookups().await; steps.mark("pull-request-lookups");`. Run the step 8 command. Expected: PASS. 10. [ ] Check the order in the source: `rg -n 'steps.mark\("(status-broadcaster|pull-request-lookups|git-writes|profiler-end-session)"\)' apps/server/src/production/runtime.rs`. Expected: the four names in exactly that order. -11. [ ] Prove the tests can fail: revert this task's implementation edits by hand (no `git stash`) back to the step 4 stubs and the bare `tokio::spawn`, keeping the tests, and rerun `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::shutdown_`. Expected: FAIL. Restore the implementation and rerun. Expected: PASS. -12. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. +11. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: quiesce_in_ production::git_vcs::tests::a_lookup_past_the_refresh_bound_stops_at_quiesce`. Expected: PASS (all three filters match tests). +12. [ ] Prove the tests can fail: revert this task's implementation edits by hand (no `git stash`) back to the step 4 stubs and the bare `tokio::spawn`, keeping the tests, and rerun the step 3 command. Expected: FAIL. Restore it and rerun. Expected: PASS. Then remove only the `shutdown_open_pull_request_lookups` call added in step 9, rerun the step 8 command. Expected: FAIL (the child outlives quiesce). Restore it and rerun. Expected: PASS. +13. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. ### Task 2.5: Remember a failed lookup for 5 seconds - Satisfies: R27 (D6): the same key answers with the failure for 5 seconds without a new lookup; a cancelled lookup (a lease drop or shutdown) is never remembered as failed; an invalidation clears it; the failure is logged once at `warn` without the remote's URL. - Depends on: 2.4 (same file and the same `Slot` enum) +- Boundaries crossed in production: `PullRequestService::resolve_current_optional_shared` hands the real cache to the status stream and the refresh; the stream test of step 2 and the new `a_failed_lookup_is_not_run_again_inside_the_backoff` cross it with the real cache and the fake `gh` - Files: modify `apps/server/src/source_control/open_pull_request_lookups.rs`; modify `apps/server/src/production/git_vcs.rs` (test `a_failed_lookup_is_not_remembered_as_no_pull_request` only); test `apps/server/src/source_control/open_pull_request_lookups.rs` - Interfaces: consumes `Slot`, `settle(&self, key: &LookupKey, id: u64, outcome: &LookupResult)`, `make_room`, `LookupResult` (its error `SourceControlProviderError` is `Clone`), the `Loader` helper and `entry_count_for_test` from Task 2.4; produces `pub(crate) const OPEN_PULL_REQUEST_FAILURE_BACKOFF: Duration = Duration::from_secs(5)`, `Slot::Failed { error: SourceControlProviderError, expires_at: Instant }`, `Admission::Failed(SourceControlProviderError)` that `resolve` returns as `Some(Err(error))`, and `settle(&self, key, id, outcome, cancelled: bool)`. `with_limits(ttl, capacity)` keeps its signature. The only caller of `settle` is the flight task in `admit` - Skills: `tdd` (new backoff behavior), `bibcode-profiler` (the warn record must hold no URL, path or branch) @@ -130,13 +135,13 @@ Steps Steps -1. [ ] Write the failing tests in `open_pull_request_lookups.rs`, all `#[tokio::test(start_paused = true)]` so time is the paused clock: +1. [ ] Write the failing tests in `open_pull_request_lookups.rs`, all `#[tokio::test(start_paused = true)]` so time is the paused clock: Expected: the change is in place, and the next command step checks it. - `a_failed_lookup_answers_from_memory_for_five_seconds`: first `resolve` returns `Some(Err(failure()))` with `loader.runs() == 1`; after `tokio::time::advance(4 s)` a second `resolve` returns `Some(Err(failure()))` with `runs() == 1`; after a further `advance(1 s + 1 ms)` a third `resolve` runs the loader (`runs() == 2`). - - `a_cancelled_lookup_is_not_remembered_as_failed`: a gated loader; the only waiter is cancelled so the lease drops and the flight token is cancelled, and the loader returns `Err(failure())`. Wait for the flight to settle before retrying: loop `tokio::task::yield_now()` until `loader.cancelled == 1` and `lookups.entry_count_for_test() == 0` (a wrongly stored failure would leave one entry). Only then call `resolve` again; assert it runs the loader (`runs() == 2`). - - `a_shutdown_flight_is_not_remembered_as_failed`: after the cancelled flight of a `shutdown`, `entry_count_for_test() == 0` (use a lookups value with capacity 64 and a gated loader; settle is awaited by `shutdown`). + - `a_cancelled_lookup_is_not_remembered_as_failed`: use a real clock (plain `#[tokio::test]`, no time advance) so a missing signal ends in a timeout, not a hang. Add `#[cfg(test)] flights_settled: tokio::sync::Notify` to `OpenPullRequestLookups`, notified with `notify_one()` by the flight task after `settle` has run (the loader's own `cancelled` counter moves before settlement and is not a settlement signal). A gated loader; the only waiter is cancelled so the lease drops and the flight token is cancelled, and the loader returns `Err(failure())`. Then `tokio::time::timeout(Duration::from_secs(30), lookups.flights_settled.notified()).await.expect("the cancelled flight settled")`, and only then assert `lookups.entry_count_for_test() == 0` and that a new `resolve` runs the loader (`runs() == 2`). A wrongly stored failure now fails the assertion instead of hanging. + - `a_shutdown_flight_is_not_remembered_as_failed`: a gated loader, `shutdown().await` (it waits for the flight, so settlement has run), then `entry_count_for_test() == 0`. - `an_invalidation_clears_a_remembered_failure`: after a failure, `lookups.invalidate(path)`, then `resolve` runs the loader again within the 5 s. - `a_failed_lookup_warns_once_without_the_remote_address`: capture `tracing` output at `WARN` with `tracing::subscriber::set_default` and a local writer type shaped like the `CapturedLog` in `production/mcp_tools_tests/git.rs`; a failure whose `detail` and `cwd` hold `https://gitlab.example.test/example-group/sample-project` and `/work/sample-project` is resolved twice inside the backoff; assert exactly one WARN line and that it contains neither `gitlab.example.test` nor `sample-project`. -2. [ ] Rewrite the existing tests that conflict with the backoff. `failures_are_shared_with_waiters_but_never_stored` is today an unpaused `#[tokio::test]` with a real 50 ms `sleep`: change it to `#[tokio::test(start_paused = true)]` and replace that sleep with `tokio::time::advance(Duration::from_millis(50))`; it keeps proving that the second caller joined the running lookup (`loader.runs() == 1`); its last assertion becomes `recovered.runs() == 0` inside 5 s (the failure answers from memory) and `recovered.runs() == 1` after `tokio::time::advance(OPEN_PULL_REQUEST_FAILURE_BACKOFF)`. Rename it `failures_are_shared_with_waiters_and_remembered_for_five_seconds`. In `git_vcs.rs`, `a_failed_lookup_is_not_remembered_as_no_pull_request` keeps its name and meaning (a failure is not stored as "no pull request") and calls `fixture.services.pull_requests().invalidate_open_pull_request_lookups(&fixture.repository)` between `clear("fail")` and the second refresh: a push, a mutation or a local change does this in production. That test uses the real clock and a real fake `gh`, so it must not advance time. +2. [ ] Add `a_failed_lookup_is_not_run_again_inside_the_backoff` to the `tests` module of `git_vcs.rs` (real clock, fake `gh`): `fixture.set("fail", "")`, call `fixture.refresh().await` twice in a row; assert `fixture.lookups() == ["main"]` (one `gh` run). Rewrite the existing tests that conflict with the backoff. `failures_are_shared_with_waiters_but_never_stored` is today an unpaused `#[tokio::test]` with a real 50 ms `sleep`: change it to `#[tokio::test(start_paused = true)]` and replace that sleep with `tokio::time::advance(Duration::from_millis(50))`; it keeps proving that the second caller joined the running lookup (`loader.runs() == 1`); its last assertion becomes `recovered.runs() == 0` inside 5 s (the failure answers from memory) and `recovered.runs() == 1` after `tokio::time::advance(OPEN_PULL_REQUEST_FAILURE_BACKOFF)`. Rename it `failures_are_shared_with_waiters_and_remembered_for_five_seconds`. In `git_vcs.rs`, `a_failed_lookup_is_not_remembered_as_no_pull_request` keeps its name and meaning (a failure is not stored as "no pull request") and calls `fixture.services.pull_requests().invalidate_open_pull_request_lookups(&fixture.repository)` between `clear("fail")` and the second refresh: a push, a mutation or a local change does this in production. That test uses the real clock and a real fake `gh`, so it must not advance time. Expected: the change is in place, and the next command step checks it. 3. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups::tests::`. Expected: FAIL on `a_failed_lookup_answers_from_memory_for_five_seconds` with `left: 2, right: 1` (the second call ran the loader). 4. [ ] Implement: `settle` takes `cancelled: bool`, passed from the flight task as `flight_cancellation.is_cancelled()` read after `lookup.await`. For `Err(error)` with `!cancelled` and the key still owned by this flight, set `Slot::Failed { error: error.clone(), expires_at: Instant::now() + OPEN_PULL_REQUEST_FAILURE_BACKOFF }`; with `cancelled`, remove the entry as today. In `admit`, an unexpired `Slot::Failed` returns `Admission::Failed(error.clone())`; an expired one falls through to a new lookup. In `make_room`, keep an unexpired `Failed` entry and let expiry and the closest-to-expiring rule remove it. Log in the flight task once per lookup that ended with an uncancelled `Err`: `tracing::warn!(provider = ?key.provider, "open pull-request lookup failed; answering with the failure for 5 seconds")`. Do not log the error, cwd, reference or command. 5. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- open_pull_request_lookups:: production::git_vcs::tests::pr_row_ production::git_vcs::tests::a_failed_lookup`. Expected: PASS (`pr_row_a_failed_lookup_keeps_the_pull_request` still passes: the stream keeps showing the request after a failure). @@ -201,22 +206,21 @@ Steps ### Task 2.8: Decide by test whether a status-neutral local change leaves a stale answer -- Satisfies: R30: a stream test invalidates during a lookup with a local change that alters no status; if the pre-invalidation answer is shown with no follow-up lookup, a follow-up lookup is started; otherwise the test stays as proof. -- Depends on: 2.6 (same files) -- Files: modify `apps/server/src/production/git_vcs.rs` (test; code only in the defect branch); modify `apps/server/src/git/broadcaster.rs` (defect branch only); test `apps/server/src/production/git_vcs.rs` -- Interfaces: consumes `Fixture`, `subscriber_showing_the_pull_request`, `wait_for_lookups`, `fixture.set`, `fixture.clear`, `StatusBroadcaster::notify_local_change(&self, cwd: &Path)` (async), `StatusBroadcaster::notify_pull_request_change(&self, cwd: &Path)` (async); produces the test `a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer` -- Skills: `mstack-diagnose` (the test decides between defect and no defect), `tdd` (defect branch only) +- Satisfies: R30: a stream test invalidates during a lookup with a local change that alters no status. Defect means the answer from before the invalidation is shown and no follow-up lookup runs; no defect means a follow-up lookup runs or the answer is replaced. +- Depends on: 2.6 (same file) +- Boundaries crossed in production: the real status stream, broadcaster, lookup cache and fake `gh` (the `Fixture` builds all of them; nothing is replaced) +- Files: test `apps/server/src/production/git_vcs.rs` +- Interfaces: consumes `Fixture`, `subscriber_showing_the_pull_request`, `wait_for_lookups` (bounded at 5 s), `Subscriber::until` (bounded), `fixture.set`, `fixture.clear`, `StatusBroadcaster::notify_local_change(&self, cwd: &Path)` (async); produces the test `a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer` +- Skills: `mstack-diagnose` (the test decides between defect and no defect) - Read first: `apps/server/src/production/git_vcs.rs` (test `a_local_change_during_a_lookup_replaces_its_outdated_answer`, which writes `saved.txt` and so changes status; `observe_local_changes`; the `publication` arm of the status stream loop that sets `look_again`); `apps/server/src/git/broadcaster.rs` (`notify_local_change`, `reconcile_remote_after_local_publication`) - Tier: most capable -- TDD: red-green -- Done when: `cargo test -p bibcode-server --lib production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer -j 2` exits 0, and the outcome line (defect fixed or no defect) is written in the pull request notes for #355 +- TDD: exempt (decide-by-test: the test is the deliverable; a defect verdict opens its own fix task) +- Done when: `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer` has run and its verdict (defect or no defect) is logged as a Ruling in the plan's progress file Steps -1. [ ] Write the test by copying `a_local_change_during_a_lookup_replaces_its_outdated_answer` with one change: after `fixture.clear("open")` do not write `saved.txt`; call `fixture.services.broadcaster.notify_local_change(&fixture.repository).await` with the working tree untouched, so the local status is byte-identical. Assert that within the helper's wait the subscriber's `pr()` becomes null and `fixture.lookups()` equals `["main", "main", "main"]`. -2. [ ] Run `cargo test -p bibcode-server --lib production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer -j 2`. Expected: PASS means no defect and FAIL means the defect branch; the next steps say what follows each. -3. [ ] If the result is PASS: this is the no-defect branch. The status owner publishes a neutral change, or the stream loop starts a follow-up lookup, so nothing changes in code. Keep the test, add the sentence `Decided by test (R30): no defect; a status-neutral local change still starts one follow-up lookup.` to the test's doc comment, and stop. -4. [ ] If the result is FAIL because the subscriber keeps showing request 42 and the lookup count stays at 2: this is the defect branch (a neutral change publishes nothing, so `look_again` is never set). Fix at the first wrong decision: in `observe_local_changes` and the local-change closure in `GitVcsRpcServices`, after `invalidate_open_pull_request_lookups`, call `broadcaster.notify_pull_request_change(cwd)` for the observer path as the closure at the `handle.spawn` site already does, so every invalidation also asks each stream to look again. The invalidation writers are `observe_local_changes` (no notify today; the fix goes here, cloning `broadcaster` into its closure), the local-change closure near the `handle.spawn` site (already notifies) and `create_with_options` in `pull_request.rs` (its callers notify). Re-run the test. Expected: PASS. -5. [ ] In the defect branch also run `cargo test -p bibcode-server --lib production::git_vcs::tests::pr_ -j 2` and `cargo test -p bibcode-server --lib production::git_vcs::tests::a_ -j 2`. Expected: PASS (no other stream test now counts an extra lookup; if one does, change its expected `fixture.lookups()` only where the extra lookup follows a real invalidation). -6. [ ] In the defect branch prove the test can fail: revert this task's code change by hand (remove the added `notify_pull_request_change` call, keeping the test) and run the test. Expected: FAIL. Restore it and run again. Expected: PASS. -7. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. +1. [ ] Write the test by copying `a_local_change_during_a_lookup_replaces_its_outdated_answer` with one change: after `fixture.clear("open")` do not write `saved.txt`; call `fixture.services.broadcaster.notify_local_change(&fixture.repository).await` with the working tree untouched, so the local status is byte-identical. The copied test uses the real `Fixture`, so no component under test is stubbed. Assert that the subscriber's `pr()` becomes null (through `Subscriber::until`, which is bounded) and that `fixture.lookups()` equals `["main", "main", "main"]`. +2. [ ] Run `cargo test -p bibcode-server --lib -j 2 -- production::git_vcs::tests::a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer`. Expected: the change is in place, and the next command step checks it. +3. [ ] Verdict 'no defect' (the test passes): keep the test as proof and add `Decided by test (R30): no defect; a status-neutral local change still starts one follow-up lookup.` to its doc comment. Log the verdict as a Ruling. +4. [ ] Verdict 'defect' (the test fails because the subscriber keeps showing the request and the lookup count stays at 2): keep the failing test, mark it `#[ignore = "R30 defect, fixed by the follow-up task"]` only if the suite must stay green meanwhile, state the first wrong decision you traced (the publication or invalidation step that does not reach the stream), open a new task in this phase for the fix with this test as its failing test, and log the verdict as a Ruling. Do not change production code in this task. +5. [ ] Run `cargo fmt --all --check`. Expected: no errors. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md index 43d4bb35a..d701d22f1 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-03-worktree-records-and-removal-test.md @@ -71,7 +71,7 @@ The dispatch-failure arm is by id for the same reason. Its end state (no record - Satisfies: R32. Scenario: a creation whose record write fails, and one whose dispatch fails, each emit exactly one `worktree.removal` event with mode `create-rollback`, and removing either production call alone fails a test. - Depends on: 3.2 (same function and arms) - Files: modify `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/mcp/tools/worktrees.rs` (header comment); test `apps/server/src/production/worktree_catalog_rpc.rs` and the existing test `the_audit_and_removal_outcomes_reach_the_profiler` in `apps/server/tests/profiler_worktree_audit.rs` -- Interfaces: consumes `worktree_audit::record_removal_outcome(mode: &str, outcome: Result<(), &str>, duration: Duration, timed_out_drain: bool, cancelled: bool)`, `GitRepository::rollback_managed_worktree_creation` (its `Err` carries `.detail`; its only callers are the two arms below). Produces: the mode string `create-rollback` and exactly two outcomes. A rollback that succeeded records `Ok(())` (event with `ok: true`, `reason: null`, no finding). A rollback that failed records `Err("internal")` (event with `ok: false`, `reason: "internal"`, plus an Error `worktree.removal-failed` finding). Never pass the rollback detail or a path. Phase 13 documents these exact strings in `events.md`. Entry points: `create_managed_worktree_locked` is reached by the `worktree.createManaged` RPC and by the MCP tool `create_worktree` in `mcp/tools/worktrees.rs`, which calls the same handler; `create_worktree_panel` creates no worktree and has no rollback. The two arms are the only audit sites. +- Interfaces: consumes `worktree_audit::record_removal_outcome(mode: &str, outcome: Result<(), &str>, duration: Duration, timed_out_drain: bool, cancelled: bool)`, `GitRepository::rollback_managed_worktree_creation` (its `Err` carries `.detail`; its only callers are the two arms below). Produces: the mode string `create-rollback` and exactly two outcomes. A rollback that succeeded records `Ok(())` (event with `ok: true`, `reason: null`, no finding). A rollback that failed records `Err("internal")` (event with `ok: false`, `reason: "internal"`, plus an Error `worktree.removal-failed` finding). Never pass the rollback detail or a path. Phase 9 documents these exact strings in `events.md`. Entry points: `create_managed_worktree_locked` is reached by the `worktree.createManaged` RPC and by the MCP tool `create_worktree` in `mcp/tools/worktrees.rs`, which calls the same handler; `create_worktree_panel` creates no worktree and has no rollback. The two arms are the only audit sites. - Skills: `tdd` (events asserted before the calls exist), `bibcode-profiler` (event shape and coverage rules) - Read first: `apps/server/src/diagnostics/profiler/worktree_audit.rs` functions `record_removal_outcome` and `removal_failure` (which reasons raise findings); `apps/server/tests/profiler_worktree_audit.rs` (the one test, its `Fixture`, and the final `removals` assertions) - Tier: standard diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md index 28608caa4..9f036e84b 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-04-update-install-guard.md @@ -3,8 +3,8 @@ - Goal: A remote update install keeps Files edits held across a reload, a lost ended event and a source registered mid-install, the installing dialog always has a way out, and the desktop event names are tied by a test, each with a failing-first test. - Round: 1 - Depends on: none -- Owns: `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/files/unsavedFileEdits.test.ts`, `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`, `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` and its test (only if Task 4.4 shows the action is unreachable), new `scripts/desktop-event-names.test.ts`; `apps/web/src/routes/__root.tsx` and `apps/web/src/routes/__root.test.tsx` (only in Task 4.4's defect branch) -- Must not touch: `apps/web/src/tauriDesktopBridge.ts` and `apps/desktop/src-tauri/**` (read only), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ui/**` +- Owns: `apps/web/src/tauriDesktopBridge.ts` and `apps/web/src/tauriDesktopBridge.test.ts` (only the `getUpdateState` entry and its tests, Task 4.2), `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/components/files/unsavedFileEdits.test.ts`, `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`, new `scripts/desktop-event-names.test.ts` +- Must not touch: `apps/desktop/src-tauri/**` (read only), the rest of `apps/web/src/tauriDesktopBridge.ts` (every entry other than `getUpdateState`), `apps/web/src/components/ThreadTerminalPanel.tsx`, `apps/web/src/components/ui/**` - Skills: `tdd` (every task is a behavior change with a failing test first), `vercel-react-best-practices` (the dialog and host are React), `better-interface` (new button and text need the UI.md review) - Read first: `AGENTS.md` (completion rules, sensitive data); `UI.md` (a dialog needs a way out, disabled states explain themselves); `docs/architecture/overview.md` section on the desktop close protocol and `desktop:update-install-ended` (current contract); `docs/plans/2026-11-10-desktop-save-and-close-design.md` (R62 and the event table) - Checkpoint: `cd apps/web && vp test run --project unit src/components/files/unsavedFileEdits.test.ts src/components/files/UnsavedChangesCloseDialog.test.tsx` and `vp test run scripts/desktop-event-names.test.ts` from the repository root both exit 0, then `vp check` and `vp run typecheck` exit 0 @@ -38,10 +38,12 @@ Steps - Satisfies: R35. Given a reload during an install this window allowed, Then saving stays held at once, the installing dialog shows with its "Check again" button, and the first state query rejecting changes nothing. Only a confirmed "no remote install in progress" answer (a query answer, an `onUpdateState` push, or the `onUpdateInstallEnded` event) clears the block. A query that fails (rejects, or gives no answer within 5 seconds) is retried and clears nothing. A fresh window with no marker holds nothing until an answer says a remote install is running. A desktop without `getUpdateState` counts as "not installing". - Depends on: 4.1 -- Files: modify `apps/web/src/components/files/unsavedFileEdits.ts`; test `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` +- Files: modify `apps/web/src/components/files/unsavedFileEdits.ts`, `apps/web/src/tauriDesktopBridge.ts` (the `getUpdateState` entry and one new helper); test `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`, `apps/web/src/tauriDesktopBridge.test.ts` - Interfaces: consumes `DesktopBridge["getUpdateState"]` and `["onUpdateState"]`; changes `respondToDesktopCloseRequests(bridge: Pick): () => void` (the last two optional at runtime); produces the non-exported `isRemoteInstallRunning(state: DesktopUpdateState | null | undefined): boolean`, `askInstallState(bridge, onAnswer: (running: boolean) => void): () => void` (returns its cancel function), and the marker helpers `readInstallMarker(): boolean` and `writeInstallMarker(on: boolean): void`, used again by Task 4.3. +- Real boundary (class C): the real bridge's `getUpdateState` is `tauriInvokeOr("desktop_bridge_get_update_state", undefined, defaultUpdateState)` in `tauriDesktopBridge.ts`, which catches every rejection and returns `defaultUpdateState()` (`enabled: false`, `requestedBy: null`). A failed native call therefore reads as a confirmed "not installing" answer and would release the block; fake-bridge tests bypass this. Fix at the source, for this query only: add a helper `tauriInvokeOrWhenUnsupported(command: string, args: Record | undefined, fallback: () => T): Promise` next to `tauriInvokeOr`. It returns `fallback()` only for the compatibility case (the desktop does not know the command: the rejection is a string containing `Command not found`, as Tauri 2 words it; confirm the exact text in the Tauri crate source before relying on it; or the `tauri_capability_unsupported` payload `isTauriDesktopCapabilityUnsupportedPayload` already recognizes). Any other rejection is rethrown through `normalizeTauriDesktopError`. `tauriInvokeOr` itself is not changed, so every other entry keeps its behavior; `getUpdateState` alone moves to the new helper. +- Other callers of `getUpdateState` (`rg getUpdateState` over `apps/web/src` and `packages`): `apps/web/src/state/desktopUpdate.ts` (`createDesktopUpdateStateAtom`) retries three times then logs `DesktopUpdateStateReadError` and publishes `null`; today that branch never runs because the bridge never rejects, so it relied on the fallback only by dead code. With the change a failure now ends in the existing `null` state instead of the disabled default; consumers already handle `null` (`state?.requestedBy ?? null` in `RemoteUpdateRequestNotifier`). `apps/web/src/components/desktop/UpdateProtectionDialog.tsx` calls `bridge?.getUpdateState().catch(() => latestState.current)`: it already tolerates rejection. Tests that read the fallback: `tauriDesktopBridge.test.ts` (`await expect(bridge.getUpdateState()).resolves.toEqual({ enabled: false, ... })` where the harness rejects unknown commands with `unsupported fallback command: ...`, an `Error`, not the Tauri wording; that expectation is rewritten in step 3 to reject with the Tauri-shaped string, which is the real compatibility case). Add a `desktopUpdate.test.ts` case only if the file asserts the old default after a failed read (open it first). - Marker: key `bibcode:update-install-allowed`, value `"1"`, kept in `window.sessionStorage` (it survives a reload of the same window and ends with the window). `rg sessionStorage` finds no per-window persisted-flag helper outside tests, so these two helpers are new and live in `unsavedFileEdits.ts`; every read and write is wrapped in `try`/`catch` and a failure means "no marker". Writers: `blockForUpdateInstall()` writes it (the one place a block starts: the allowed `update` reply, the load-time restore and a confirmed running answer). Clearers: `endUpdateInstall()` only, which every confirmed end already reaches (the ended event, a pushed state, a confirmed query answer, the cancelled late answer). Nothing else removes it, and no failure path does. -- Rule on load: a present marker calls `blockForUpdateInstall()` at once, before any query, so the installing dialog (with "Check again") shows and no save runs silently. With no marker, nothing is held until an answer says `isRemoteInstallRunning`. If the desktop has no `getUpdateState`, a present marker cannot be confirmed either way, so it is cleared and nothing is held. +- Rule on load: a present marker calls `blockForUpdateInstall()` at once, before any query, so the installing dialog shows (its "Check again" button is Task 4.3's) and no save runs silently. With no marker, nothing is held until an answer says `isRemoteInstallRunning`. If the desktop has no `getUpdateState`, a present marker cannot be confirmed either way, so it is cleared and nothing is held. - Retry rule: `askInstallState` calls `getUpdateState()` with a 5 second timeout. A rejection or timeout is not an answer: it logs once with `console.warn` and asks again every 2 seconds, without limit. The loop stops on the first confirmed answer, on any state pushed by `onUpdateState`, and on cleanup. - Limit stated plainly: a unit test cannot prove the marker survives a relaunched web process. The tests below reload by resetting the module registry (`vi.resetModules()` and importing `unsavedFileEdits` and the dialog again) with the same `sessionStorage`. The proof that a real reload keeps the marker is a packaged check in Phase 8: reload the window during a mocked remote install and assert the DOM state `[role="alertdialog"]` containing the text "Installing an update" and a button named "Check again" while the marker `sessionStorage["bibcode:update-install-allowed"] === "1"`. - Skills: tdd (red-green cycle) @@ -52,15 +54,16 @@ Steps Steps -1. [ ] In the describe "a remote update install the window allowed", extend the fake bridge so `getUpdateState` and `onUpdateState` can be set per test. Time is controlled with `vi.useFakeTimers()` in the new tests (restore real timers in `afterEach`); replace `afterTheSaveDelay` in them with `await vi.advanceTimersByTimeAsync(700)`; after advancing time past a rejected query, `await` the pending promise (`await vi.advanceTimersByTimeAsync(0)`) before asserting. -2. [ ] Write the failing test `reload during an install: the marker restores the block while the first query rejects`: allow an install through the close protocol (marker written), reset modules and remount the host, `getUpdateState` rejects; assert the dialog text contains "Installing an update", a button "Check again" exists, the session's `persist` is not called after advancing 700 ms, and the query is asked again after 2 seconds. +1. [ ] In the describe "a remote update install the window allowed", extend the fake bridge so `getUpdateState` and `onUpdateState` can be set per test. Time is controlled with `vi.useFakeTimers()` in the new tests; every wait is `vi.waitFor(..., { timeout: 30_000 })` or an explicit timer advance, never a yield loop (restore real timers in `afterEach`); replace `afterTheSaveDelay` in them with `await vi.advanceTimersByTimeAsync(700)`; after advancing time past a rejected query, `await` the pending promise (`await vi.advanceTimersByTimeAsync(0)`) before asserting. +2. [ ] Write the failing test `reload during an install: the marker restores the block while the first query rejects`: allow an install through the close protocol (marker written), reset modules and remount the host, `getUpdateState` rejects; assert the dialog text contains "Installing an update", the session's `persist` is not called after advancing 700 ms, and the query is asked again after 2 seconds. 3. [ ] Write the failing test `reload during an install: a confirmed not-running answer clears the block and the marker`: continue from step 2, the retry answers `{ requestedBy: null, phase: "idle" }`; assert the dialog is gone, `sessionStorage.getItem("bibcode:update-install-allowed")` is null and saving resumes. -4. [ ] Write the failing test `a fresh window with no marker holds nothing until an answer says an install runs`: no marker, query pending; assert no dialog and `session.flush()` saves; then answer `{ requestedBy: { label: "Test User", detail: null }, phase: "installing" }`; assert the dialog shows and the marker is written. Add `a failed query never releases a known block` (timeout past 5 seconds and rejection: dialog and marker stay), `a pushed update state ends the wait`, `a desktop without getUpdateState clears a stale marker and holds nothing`, and `an unusable sessionStorage means no marker and no crash` (make `getItem` and `setItem` throw). -5. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "reload during an install|fresh window|failed query never|pushed update state ends|without getUpdateState|unusable sessionStorage"`. Expected: FAIL (no marker, no dialog, no retry). Every selector is a substring of a title written here. -6. [ ] Implement the marker helpers, `isRemoteInstallRunning` and `askInstallState`; write the marker in `blockForUpdateInstall`, clear it in `endUpdateInstall`, restore on load as the rules above say, and cancel the loop in the cleanup `respondToDesktopCloseRequests` returns. -7. [ ] Run the step 5 command. Expected: PASS. -8. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 5 command. Expected: FAIL. Restore it and rerun. Expected: PASS. -9. [ ] Run `cd apps/web && vp check src/components/files/unsavedFileEdits.ts src/components/files/UnsavedChangesCloseDialog.test.tsx`. Expected: no errors. +4. [ ] Write the failing bridge tests in `tauriDesktopBridge.test.ts` with the existing harness (real `window.desktopBridge` from `tauriDesktopBridgeReady`, native `invoke` mocked): `getUpdateState rejects when the native call fails` (`harness.invoke` rejects `desktop_bridge_get_update_state` with `new Error("boom")`; expect `bridge.getUpdateState()` rejects with it), `getUpdateState answers the default when the desktop lacks the command` (reject with the string `Command desktop_bridge_get_update_state not found`; expect the `enabled: false` default), the same for a `tauri_capability_unsupported` payload, and `other entries still fall back on a native failure` (for example `getClientSettings`, unchanged). Rewrite the existing `resolves.toEqual({ enabled: false ...` expectation to reject with the Tauri-shaped string. Then add `a native failure through the real bridge keeps the restored block` in the same file: install the bridge, set the marker, call the real `respondToDesktopCloseRequests(window.desktopBridge)` from `./components/files/unsavedFileEdits`, make `invoke` reject, advance fake timers past two retries and assert `isUpdateInstalling()` is still true and the marker is still set. +5. [ ] Write the failing test `a fresh window with no marker holds nothing until an answer says an install runs`: no marker, query pending; assert no dialog and `session.flush()` saves; then answer `{ requestedBy: { label: "Test User", detail: null }, phase: "installing" }`; assert the dialog shows and the marker is written. Add `a failed query never releases a known block` (timeout past 5 seconds and rejection: dialog and marker stay), `a pushed update state ends the wait`, `a desktop without getUpdateState clears a stale marker and holds nothing`, and `an unusable sessionStorage means no marker and no crash` (make `getItem` and `setItem` throw). +6. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx src/tauriDesktopBridge.test.ts -t "reload during an install|fresh window|failed query never|pushed update state ends|without getUpdateState|unusable sessionStorage|getUpdateState rejects|lacks the command|other entries still fall back|through the real bridge"`. Expected: FAIL (no marker, no dialog, no retry; the bridge still swallows the rejection). Every selector is a substring of a title written here. +7. [ ] Implement the bridge helper and the `getUpdateState` change, then the marker helpers, `isRemoteInstallRunning` and `askInstallState`; write the marker in `blockForUpdateInstall`, clear it in `endUpdateInstall`, restore on load as the rules above say, and cancel the loop in the cleanup `respondToDesktopCloseRequests` returns. +8. [ ] Run the step 6 command, then `cd apps/web && vp test run --project unit src/tauriDesktopBridge.test.ts src/state src/components/desktop`. Expected: PASS (no other entry changed behavior). +9. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 6 command. Expected: FAIL. Restore it and rerun. Expected: PASS. +10. [ ] Run `cd apps/web && vp check src/components/files/unsavedFileEdits.ts src/tauriDesktopBridge.ts src/tauriDesktopBridge.test.ts src/components/files/UnsavedChangesCloseDialog.test.tsx`. Expected: no errors. ### Task 4.3: Give the installing dialog a way out @@ -77,8 +80,8 @@ Steps Steps 1. [ ] Write the failing test `a pushed update state with no remote install closes the installing dialog` (fake bridge captures the `onUpdateState` listener; after allow, push `{ requestedBy: null, phase: "idle" }`; assert the dialog is gone, saving resumes, and the "The update did not install" toast was added). -2. [ ] Write the failing test `Check again asks the desktop for the state and closes the dialog when no install runs`: dialog shows a button named "Check again"; `getUpdateState` resolves a not-running state; click; assert the dialog closes. Also `Check again with an install still running keeps the dialog`, and `a rejecting state query keeps the dialog, the block, the marker and the button, and the 4.2 retry keeps asking`. -3. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "pushed update state|Check again|rejecting state"`. Expected: FAIL (no such button, dialog stays). +2. [ ] Write the failing test `Check again asks the desktop for the state and closes the dialog when no install runs`: dialog shows a button named "Check again"; `getUpdateState` resolves a not-running state; click; assert the dialog closes. Also `Check again with an install still running keeps the dialog`, `a block restored after a reload shows the Check again button` (the Task 4.2 reload setup: marker present, the first query rejecting; assert the dialog has a button named "Check again", click it with the next query answering not-running, and assert the dialog closes and the marker is gone; this is where the button assertions of the reload scenario live), and `a rejecting state query keeps the dialog, the block, the marker and the button, and the 4.2 retry keeps asking`. +3. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "pushed update state with no remote install|Check again|restored after a reload|rejecting state query"`. Expected: FAIL (no such button, dialog stays). 4. [ ] Implement the `onUpdateState` subscription and `recheckUpdateInstall`, and add the "Check again" `Button variant="outline"` in a footer of `UpdateInstallingDialog` with a disabled state while the query runs. The description keeps its current text and gains one sentence: "If it seems stuck, check again." UI.md review: confirm the dialog now has a visible exit, the button is not the primary action, and Escape still does nothing while an install may restart the app. 5. [ ] Run the same command. Expected: PASS. 6. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed) and rerun the step 3 command. Expected: FAIL. Restore it and rerun. Expected: PASS. @@ -86,23 +89,23 @@ Steps ### Task 4.4: Decide whether the dialog hides the Manage devices toast -- Satisfies: R39 (decide by test). Given the installing dialog open over the remote-update toast, Then either the toast action is reachable (the test stays as proof) or the action is made reachable from the dialog. +- Satisfies: R39 (decide by test). Given the installing dialog open over the remote-update toast, Then either the toast action is reachable (the test stays as proof) or it is inert (a defect). - Depends on: 4.3 -- Files: modify `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx`; only on a defect, modify `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx`, `apps/web/src/routes/__root.tsx` (the four `UnsavedChangesCloseHost` mounts) and `apps/web/src/components/RemoteUpdateRequestNotifier.tsx` if its action must change; test `apps/web/src/routes/__root.test.tsx` on a defect -- Interfaces: consumes `RemoteUpdateRequestNotifier({ onManageDevices })` and the toast manager from `~/components/ui/toast`; on a defect produces an optional `onManageDevices?: () => void` prop on `UnsavedChangesCloseHost`, shown as a secondary button "Manage devices" in the installing dialog and absent when the prop is absent -- Skills: mstack-diagnose (the result of a DOM probe picks the branch); better-interface (any new action needs the UI.md review) -- Read first: `apps/web/src/components/ui/toast.tsx` (`Toast.Portal`, `Toast.Viewport`); `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; `apps/web/src/AppRoot.tsx` (mounts the notifier only, with a `router.navigate({ to: "/settings/remote-servers", search: { tab: "share" } })` callback); `apps/web/src/routes/__root.tsx` (mounts `UnsavedChangesCloseHost` at four sites: the `/pair` route, the unauthenticated gate states, the authenticated layout, and `RootRouteErrorView`); `apps/web/src/routes/__root.test.tsx` (existing route tests) +- Files: modify `apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx` +- Interfaces: consumes `RemoteUpdateRequestNotifier({ onManageDevices })` and the toast manager from `~/components/ui/toast` (the real `Toast.Portal` and `Toast.Viewport`, not the mocked manager); produces the verdict only +- Skills: mstack-diagnose (the result of a DOM probe picks the branch) +- Read first: `apps/web/src/components/ui/toast.tsx` (`Toast.Portal`, `Toast.Viewport`); `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`; `apps/web/src/components/files/UnsavedChangesCloseDialog.tsx` (`UpdateInstallingDialog`) - Tier: most capable -- TDD: exempt (the test is the deliverable; code changes only on a defect, and then red-green) -- Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast"` exits 0 and the outcome line is recorded in the final report +- TDD: exempt (the test is the deliverable) +- Done when: `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast action"` exits 0 or fails only on the reachability assertion, and the verdict is logged as a Ruling Steps -1. [ ] Write the DOM test `the Manage devices toast action is reachable while the installing dialog is open`: mount the real toast viewport (not the mocked manager), add a toast with an action as the notifier does, open the installing dialog, then assert whether the action element has an `[inert]` or `[aria-hidden="true"]` ancestor and whether `document.elementFromPoint` or a keyboard Tab can reach it. -2. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast"`. Expected: either PASS (no defect) or FAIL on the inert assertion (defect). -3. [ ] Result "no defect" (PASS): keep the test as proof, add no code, and write "R39: no defect, toast action reachable over the dialog" in the final report. -4. [ ] Result "defect" (FAIL): add the "Manage devices" secondary button to `UpdateInstallingDialog` through an `onManageDevices` prop. The host is mounted by `routes/__root.tsx`, not `AppRoot.tsx`: pass the prop only at the authenticated-layout mount, using the router's navigate to `/settings/remote-servers` with `search: { tab: "share" }`; the `/pair`, gate-state and error-view mounts pass none, so the button is absent there (those states have no Settings to open). Add tests: in the dialog test file that the click calls the prop and that no button shows without it; in `routes/__root.test.tsx` that the authenticated layout passes a working callback and the other three mounts pass none (read that file first and follow how it renders the layout). Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx src/routes/__root.test.tsx`. Expected: PASS. Revert the change by hand and rerun. Expected: FAIL; restore. Run the UI.md review on the new action. -5. [ ] Run `vp check` on changed files. Expected: no errors. +1. [ ] Write the DOM test `the toast action is reachable while the installing dialog is open`: mount the real toast viewport, add a toast with an action as the notifier does, open the installing dialog, then assert that the action element has no `[inert]` or `[aria-hidden="true"]` ancestor and that a click on it (`dispatchEvent` through the real DOM, not `.click()` on a detached node) reaches its handler. Use an explicit signal for the dialog being open (`await vi.waitFor(..., { timeout: 30_000 })`). +2. [ ] Run `cd apps/web && vp test run --project unit src/components/files/UnsavedChangesCloseDialog.test.tsx -t "toast action"`. A pass means no defect; a failure on the reachability assertion means defect. Expected: the change is in place, and the next command step checks it. +3. [ ] Verdict "no defect": keep the test as proof and log "R39: no defect, the toast action is reachable over the installing dialog" as a Ruling. +4. [ ] Verdict "defect": open a new task in this phase for the fix, stating the failing test (this one) and the first wrong decision that step 2 names (which element is inert and what made it so); its requirement is spec R39's wording: device management usable while the installing dialog is up, reached from the dialog and shown above it, with Files saving still held, not a callback that navigates the page beneath the dialog. Log the verdict as a Ruling. Write no fix here. +5. [ ] Run `cd apps/web && vp check src/components/files/UnsavedChangesCloseDialog.test.tsx`. Expected: no errors. ### Task 4.5: Fail when a desktop event name exists on one side only diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md index 9bcf91f70..afab53314 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-05-reasoning-row-scroll-and-dashboard.md @@ -1,6 +1,6 @@ # Phase 5: Reasoning row scroll and Host dashboard re-render -- Goal: Expanding or collapsing a reasoning row that the reader brought into view keeps its header within 2 px (R12, diagnosed first, fixed at the first wrong decision), and the Host dashboard values view no longer re-renders on the clock tick alone (R18), each with its tests. +- Goal: Expanding or collapsing a reasoning row that the reader brought into view keeps its header within 2 px (R12, diagnosed on the real app first, fixed at the first wrong decision the trace names), and the Host dashboard values view is measured and, only if a clock tick costs it a render, stops re-rendering on the tick alone (R18), each with its tests. - Round: 1 - Depends on: none - Owns: `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/ChatView.test.tsx`, `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx`, `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (only if the diagnosis lands there), `apps/web/src/components/chat/MessagesTimeline.tsx` and `apps/web/src/components/chat/MessagesTimeline.test.tsx` (temporary instrumentation in Task 5.1; the `onReasoningRowToggle` type and call in Task 5.2), `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` @@ -25,15 +25,15 @@ Paths in this plan: the repository keeps `timelineScrollAnchoring.ts` and `Messa ### Task 5.1: Diagnose which path scrolls to the end after a programmatic scroll and a toggle -- Satisfies: R12 (diagnose-first step 2). Scenario pinned: Given the reader scrolled a reasoning row to the center without a wheel or touch gesture, When the row is toggled, Then no scroll to the end is made within three animation frames. +- Satisfies: R12 (diagnose-first). Scenario pinned: Given the reader scrolled a reasoning row to the center without a wheel or touch gesture, When the row is toggled, Then no scroll to the end is made within three animation frames. - Depends on: none -- Files: modify (temporary, removed before the task closes) `apps/web/src/components/ChatView.tsx` and `apps/web/src/components/chat/MessagesTimeline.tsx`; modify `apps/web/src/components/ChatView.test.tsx` or `apps/web/src/components/chat/MessagesTimeline.test.tsx` (whichever the trace names, see step 8) -- Interfaces: consumes every scroll call site in `ChatView.tsx` (found with `rg "scrollToOffset|scrollToEnd|scrollToIndex" apps/web/src/components/ChatView.tsx`): `scrollToEnd` (the pill, `onClick={() => scrollToEnd(true)}`), the page-key `scrollToOffset`, the anchor-positioning `scrollToOffset` calls, the pending-offset `scrollToOffset`, `onReasoningRowToggle` (H1), `followTimelineLiveEnd` (H2, its `scrollToOffset`, its `recheck` `scrollToOffset` and its `scrollToEnd` in mode `following-end`) and the thread-open `scrollToEnd`; in `MessagesTimeline.tsx` the minimap `scrollToIndex` and the `maintainScrollAtEnd` prop passed to `LegendList`. Produces: a recorded verdict (a comment above the new test) naming the path, and a unit test that reproduces that path through the real toggle. +- Files: modify (temporary, removed before the task closes) `apps/web/src/components/ChatView.tsx` and `apps/web/src/components/chat/MessagesTimeline.tsx`; modify `apps/web/src/components/ChatView.test.tsx` +- Interfaces: consumes every scroll call site in `ChatView.tsx` (found with `rg "scrollToOffset|scrollToEnd|scrollToIndex" apps/web/src/components/ChatView.tsx`): `scrollToEnd` (the pill, `onClick={() => scrollToEnd(true)}`), the page-key `scrollToOffset`, the anchor-positioning `scrollToOffset` calls, the pending-offset `scrollToOffset`, `onReasoningRowToggle` (H1), `followTimelineLiveEnd` (H2, its `scrollToOffset`, its `recheck` `scrollToOffset` and its `scrollToEnd` in mode `following-end`) and the thread-open `scrollToEnd`; in `MessagesTimeline.tsx` the minimap `scrollToIndex` and the `maintainScrollAtEnd` prop passed to `LegendList`. Produces: a verdict logged as a Ruling (the scrolling path, the trace lines that show it, the first wrong decision) and one failing unit test, `it.fails`, titled with `R12 scroll path`, that reproduces that path through the real `ChatView` toggle-to-prop path. - Skills: mstack-diagnose (live trace first; no fix before the verdict) - Read first: `apps/web/src/components/ChatView.tsx` `onIsAtEndChange`, `onReasoningRowToggle`, `followGrownTurn`, `onTimelineRowSizeChanged`, `followTimelineLiveEnd`; `apps/web/src/components/chat/timelineScrollAnchoring.ts` `rowToggleNeedsEndReveal`; `apps/web/src/components/chat/MessagesTimeline.logic.ts` `resolveTimelineMaintainScrollAtEnd` (false whenever anchored); `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end" and its file-level mocks (see step 8); `apps/desktop/e2e/specs/chat-reasoning-rows.e2e.ts` `expandThoughtAwayFromEnd` - Tier: most capable - TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx -t "R12 scroll path"` exits 0; the verdict comment names H1, H2, H3 or another path with the trace line that shows it; and `rg "\[DEBUG-r12\]" apps/web/src` finds nothing. +- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` exits 0; the Ruling names the path with its trace lines; and `rg "\[DEBUG-r12\]" apps/web/src` finds nothing. Steps @@ -41,58 +41,55 @@ Steps 2. [ ] Optional cheaper first probe, on the Mac: run the web app in Chromium at device pixel ratio 2 (device metrics emulation) and perform the same sequence as `expandThoughtAwayFromEnd`: bring a finished reasoning row to the center with `scrollIntoView({ block: "center" })`, press its header as `toggleThought` does (a `pointerdown`, a `pointerup`, then `click`), and read the header's top before and 300 ms after. The app is started with `vp dev` from `apps/web` (`package.json` script `dev`); the thread with reasoning rows needs the fixture server and Claude shim the packaged spec uses (`apps/desktop/e2e/support/test-project.ts`, `provider-shims.ts`). If no browser route to that fixture exists, skip this step and say so; do not build one. If the probe reproduces (header moved more than 2 px), take the trace of step 4 from it, and the VM run only confirms the fix. If it does not, the trace comes from the VM. Expected: the change is in place, and the next command step checks it. 3. [ ] Add temporary instrumentation, every line prefixed `[DEBUG-r12]`, with `console.info("[DEBUG-r12]", JSON.stringify({ site, caller, mode, scroll, scrollDeltaToRevealEnd, maintainScrollAtEnd, t: performance.now() }))`. Place it at every scroll call site listed under Interfaces (`site` names the function; `caller` names who invoked it: `toggle`, `grownTurn`, `liveFollow`, `recheck`, `pageKey`, `pill`, `anchor`, `threadOpen`, `minimap`; `mode` is `timelineScrollModeRef.current`; `scroll` is `list.getState().scroll`; `scrollDeltaToRevealEnd` is the value the site used, or null). In `MessagesTimeline.tsx` log the value actually passed as `maintainScrollAtEnd` (the object or `false`) once per change, and log in the existing scroll handler (`handleScroll`) each scroll with `scrollTop` and the time since the last `[DEBUG-r12]` call-site line. A scroll with no call-site line before it is the list's own. Log nothing that is message text or a path. Do not run `vp check` while this is in the tree. Expected: the change is in place, and the next command step checks it. 4. [ ] Hand the instrumented working tree to the coordinator, who builds it and drives the run (the VM run of the instrumented build, or the Chromium probe of step 2 when it reproduced). Stop here until the `[DEBUG-r12]` lines come back. If none come back, report that and do not guess a path. Expected: the change is in place, and the next command step checks it. -5. [ ] Read the lines for the sequence around the toggle and classify by the first scroll after the `click`: (H1) a `site=onReasoningRowToggle` line about two frames after the click with `mode=anchoring-new-turn` and `scroll` near 0; (H2) a `site=followTimelineLiveEnd` line with `caller=grownTurn`; (H3) a scroll with no call-site line and `maintainScrollAtEnd` logged as an object, not `false`; (other) a scroll with no call-site line and `maintainScrollAtEnd=false`, or a line from any other site, which is then not one of the three: stop and report it to the coordinator with the lines. -6. [ ] Write the verdict comment: the winning path, the trace metrics (`scroll`, `scrollDeltaToRevealEnd`, `mode`, `maintainScrollAtEnd`, the growth in px, the frames between click and scroll), and the first wrong decision: for H1 the reveal decision in `onReasoningRowToggle` (it cannot tell the toggled row from the newest row), for H2 the follow decision in `followGrownTurn` (same blind spot), for H3 the end maintenance staying enabled after a programmatic scroll away from the end. +5. [ ] Read the lines for the sequence around the toggle and classify by the first scroll after the `click`: (H1) a `site=onReasoningRowToggle` line about two frames after the click with `mode=anchoring-new-turn` and `scroll` near 0; (H2) a `site=followTimelineLiveEnd` line with `caller=grownTurn`; (H3) a scroll with no call-site line and `maintainScrollAtEnd` logged as an object, not `false`; (other) a scroll with no call-site line and `maintainScrollAtEnd=false`, or a line from any other site: not one of the three, so stop and report it to the coordinator with the lines. Also note in the Ruling whether the toggled row was inside a grouped work row (`[thought A, tool B, thought C]`), because the trace may show the reveal firing for a non-newest thought in a group. +6. [ ] Log the verdict as a Ruling: the winning path, the trace metrics (`scroll`, `scrollDeltaToRevealEnd`, `mode`, `maintainScrollAtEnd`, the growth in px, the frames between click and scroll) and the first wrong decision. Verdict "defect" (a path scrolled) leads to Task 5.2, which this task does not pre-write. 7. [ ] Remove all instrumentation by hand. Run `rg "\[DEBUG-r12\]" apps/web/src`. Expected: no matches. -8. [ ] Write the unit test that reproduces the named path with the trace's metrics, exercising the real toggle, as `it.fails` with a title containing `R12 scroll path`. Harness facts, from the file as it is: `ChatView.test.tsx` mocks `./chat/MessagesTimeline` with a stub that renders the real component only when `h.realTimeline` is true (as the tests that set `h.realTimeline = true` do), mocks `@legendapp/list/react` with a stub that renders rows and drops every other prop (so `maintainScrollAtEnd` is invisible there), and gives the list through `h.timelineList`. The existing #173 test leaves `h.realTimeline` false and calls `capturedProps("messagesTimeline")["onReasoningRowToggle"]()` directly, which skips the real header button; copying it unchanged would test the callback and not the toggle. So: for H1 and H2 add a new test in `ChatView.test.tsx` with `h.realTimeline = true`, a thread whose `reasoning` field holds one finished thought (the thread builder has `reasoning: []` at its default), `h.timelineList.getState().data` set to the rows `deriveMessagesTimelineRows` produces for it, the trace's `scroll`, `scrollLength` and a last-row growth, no `pointerdown` on the list, `onIsAtEndChange(false)` to stand for the programmatic scroll, and a click on the real header button `[data-reasoning-id] button` (an H2 test drives growth with `onRowSizeChanged({ index, previous, size })` after that click). For H3 add the test to `MessagesTimeline.test.tsx`, whose `@legendapp/list/react` stub exposes `data-maintain-scroll-at-end`: render the real timeline with `anchoredEndSpace` unset and `followsEnd` as the trace shows, and assert the attribute is absent after a non-newest toggle. State in the verdict comment which stub the test relies on and what it cannot see. -9. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: PASS (the `it.fails` test fails inside as intended). Then change that test to `it` and run again. Expected: FAIL with a scroll to an offset at or above the old end (H1, H2) or the attribute present (H3). Restore `it.fails` and run again. Expected: PASS. -10. [ ] Run `vp check apps/web/src/components/ChatView.test.tsx apps/web/src/components/chat/MessagesTimeline.test.tsx`. Expected: no errors. +8. [ ] Write the failing unit test in `ChatView.test.tsx` through the real `ChatView` toggle-to-prop path, not `MessagesTimeline` alone. The file-level mocks, as they stand: `./chat/MessagesTimeline` is a stub that renders the real component only when `h.realTimeline` is true (tests near the ones that set `h.realTimeline = true` show the setup) and always records the props `ChatView` passes in `h.captured["messagesTimeline"]`; `@legendapp/list/react` is a stub that renders rows and drops every other prop, so `maintainScrollAtEnd` is invisible; the list ref is `h.timelineList`. The existing #173 test leaves `h.realTimeline` false and calls the captured `onReasoningRowToggle()` directly, which skips the real header button, so copying it unchanged would test the callback and not the toggle. So: set `h.realTimeline = true`; extend the `@legendapp/list/react` stub in this file to also record its props (`h.captured["legendList"] = props`) while still rendering the rows, which is an explicit harness change; seed a thread whose `reasoning` field holds the thoughts the trace involved (the default fixture has `reasoning: []`); set `h.timelineList.getState().data` to the rows `deriveMessagesTimelineRows` produces for it; reproduce the trace's `scroll`, `scrollLength`, `mode` and growth; send no `pointerdown` on the list; call the captured `onIsAtEndChange(false)` to stand for the programmatic scroll; click the real header button `[data-reasoning-id] button`. Assert what the verdict names: for an H1 or H2 verdict, that `h.timelineList.scrollToOffset`, `scrollToIndex` and `scrollToEnd` are not called within three animation frames (an H2 test drives the growth with the captured `onRowSizeChanged({ index, previous, size })` after the click); for an H3 verdict, that `h.captured["legendList"].maintainScrollAtEnd` is `false` after the toggle. A frame wait uses `requestAnimationFrame` three times inside `act`, bounded by the test's own timeout. Write it as `it.fails`. +9. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: PASS (the `it.fails` test fails inside as intended). Change it to `it` and run again. Expected: FAIL with a scroll call at an offset at or above the old end, or `maintainScrollAtEnd` not false. Restore `it.fails`, run again. Expected: PASS. +10. [ ] Run `vp check apps/web/src/components/ChatView.test.tsx` from `apps/web`. Expected: no errors. -### Task 5.2: Fix the first wrong decision the diagnosis names +### Task 5.2: Fix the first wrong decision the Ruling names -- Satisfies: R12. Scenarios pinned: Given a programmatic scroll to a reasoning row and a toggle, Then no scroll to the end is made within three animation frames; Given the newest reasoning row at the end of an anchored turn, Then the #173 reveal still happens. +- Satisfies: R12. Scenarios pinned (all three must pass): (a) Given the reader scrolled a reasoning row to the center without a wheel or touch gesture, When the row is toggled, Then no scroll to the end is made within three animation frames. (b) Given one work row that groups `[thought A, tool B, thought C]`, When thought A is toggled, Then its header stays in place, and toggling C at the turn's end still reveals the end. (c) Given the newest reasoning row at the end of an anchored turn, Then the existing reveal still happens (the #173 behavior). - Depends on: 5.1 -- Files: modify `apps/web/src/components/ChatView.tsx`; modify `apps/web/src/components/chat/MessagesTimeline.tsx` (H1, H2); modify `apps/web/src/components/chat/timelineScrollAnchoring.ts` and `apps/web/src/components/chat/timelineScrollAnchoring.test.tsx` (H1, H2); modify `apps/web/src/components/chat/MessagesTimeline.logic.ts` and `apps/web/src/components/chat/MessagesTimeline.logic.test.ts` (H3 only if the fix is in the predicate); test `apps/web/src/components/ChatView.test.tsx`, `apps/web/src/components/chat/MessagesTimeline.test.tsx` -- Interfaces: consumes the verdict comment and the `it.fails` test of Task 5.1. Writers and readers of `onReasoningRowToggle`, all of which a branch that changes it must cover: the type in `MessagesTimeline.tsx` `TimelineRowSharedState` (`onReasoningRowToggle: () => void`) and in `MessagesTimelineProps`; the destructure in `MessagesTimeline`; the row-context value built there (and its dependency list); the call in `ReasoningWorkEntryRow` (`onReasoningRowToggle()` inside `toggleExpanded`); the definition and the prop pass in `ChatView.tsx`; the test doubles `onReasoningRowToggle: () => {}` in `MessagesTimeline.test.tsx` and `useThreadFind.test.tsx` (still valid, as a function with fewer parameters is assignable); the assertion `expect(onReasoningRowToggle).toHaveBeenCalledTimes(2)` in `MessagesTimeline.test.tsx`; the direct call `(timeline["onReasoningRowToggle"] as () => void)()` in the #173 test of `ChatView.test.tsx`. Produces, by confirmed branch, and a branch may combine with another: - - H1 (the toggle's reveal): `onReasoningRowToggle: (reasoningId: string) => void` in `TimelineRowSharedState` and `MessagesTimelineProps`; `ReasoningWorkEntryRow` calls `onReasoningRowToggle(reasoning.id)`; `rowToggleNeedsEndReveal(input: { mode: TimelineScrollMode; scrollDeltaToRevealEnd: number | null; toggledRowIsNewest: boolean }): boolean` returns false when `toggledRowIsNewest` is false; new `isNewestReasoningRow(rows: ReadonlyArray<{ kind: string; groupedEntries?: ReadonlyArray<{ reasoning?: { id: string } }> }>, reasoningId: string): boolean` in `timelineScrollAnchoring.ts`, true when the row holding that id has no later row except of kind `working`; `ChatView.onReasoningRowToggle(reasoningId)` computes it from `legendListRef.current.getState().data`. - - H2 (the grown-turn follow): the same signature change and the same `isNewestReasoningRow`, plus `ChatView` keeps `toggledReasoningRowRef` (`{ index: number; newest: boolean } | null`), set by `onReasoningRowToggle(reasoningId)` and cleared after two animation frames; `onTimelineRowSizeChanged` returns early when `info.index` is the toggled row's index and `newest` is false. Because the H2 test of Task 5.1 starts from the real toggle, it now reaches the code that sets the ref. - - H3 (list-internal end maintenance): `ChatView.onReasoningRowToggle(reasoningId)` (same signature change) calls `setTimelineFollowsEnd(false)` when the row is not the newest, `onIsAtEndChange(true)` restores it, so the real `maintainScrollAtEnd` value passed by `MessagesTimeline` becomes `false`; if the trace showed the value was already `false` the branch is not H3. +- Files: modify `apps/web/src/components/ChatView.tsx`, and only the other files the Ruling's first wrong decision requires among `apps/web/src/components/chat/timelineScrollAnchoring.ts`, `apps/web/src/components/chat/MessagesTimeline.tsx`, `apps/web/src/components/chat/MessagesTimeline.logic.ts` and their tests; test `apps/web/src/components/ChatView.test.tsx` +- Interfaces: consumes the Ruling and the `it.fails` test of Task 5.1. The fix goes at the first wrong decision the Ruling names and nowhere else. No fix is chosen here in advance. The caller surface known today, which any change to the toggle callback must cover: `onReasoningRowToggle` is declared as `() => void` in `MessagesTimeline.tsx` in `TimelineRowSharedState` and in `MessagesTimelineProps`; destructured in `MessagesTimeline`; put in the row-context value (and its dependency list); called in `ReasoningWorkEntryRow` inside `toggleExpanded`; defined and passed as a prop in `ChatView.tsx`; doubled as `onReasoningRowToggle: () => {}` in `MessagesTimeline.test.tsx` and `threadFind/useThreadFind.test.tsx`; asserted by `expect(onReasoningRowToggle).toHaveBeenCalledTimes(2)` in `MessagesTimeline.test.tsx`; and called directly as `(timeline["onReasoningRowToggle"] as () => void)()` in the #173 test of `ChatView.test.tsx`. A reasoning entry's identity is `reasoning.id` on a work entry inside a row's `groupedEntries`; several consecutive work entries share one list row (`deriveMessagesTimelineRows`), and each reasoning entry renders its own toggle, so "the newest thought" cannot be decided from list rows alone: entries that follow the toggled one inside its group count as well. Produces: the three scenarios passing, the test of Task 5.1 flipped to `it`. - Skills: tdd (red-green for the changed decision); vercel-react-best-practices (review of changed callbacks and refs) -- Read first: the verdict comment from Task 5.1; `apps/web/src/components/chat/MessagesTimeline.tsx` `ReasoningWorkEntryRow`; `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end", whose fixture has anonymous list rows (`data: [{}, {}]`) and calls the callback with no argument; both change in this task (see step 2) +- Read first: the Ruling from Task 5.1; `apps/web/src/components/chat/MessagesTimeline.tsx` `ReasoningWorkEntryRow` and where `groupedEntries` are rendered; `apps/web/src/components/chat/MessagesTimeline.logic.ts` `deriveMessagesTimelineRows` (how entries group into one `work` row); `apps/web/src/components/ChatView.test.tsx` test "a pointer press on $pressed before a toggle at an anchored turn's end", whose fixture has anonymous list rows (`data: [{}, {}]`) and calls the callback with no argument, and its file-level mocks (the timeline stub renders the real component only when `h.realTimeline` is true) - Tier: standard - TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts` exits 0, including the #173 test with its updated fixture. +- Done when: from `apps/web`, `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx src/components/chat/timelineScrollAnchoring.test.tsx src/components/chat/MessagesTimeline.logic.test.ts` exits 0, including the #173 test. Steps -1. [ ] Flip the test of Task 5.1 from `it.fails` to `it`. It is the failing test of this task. -2. [ ] Update the #173 test fixture for the new signature, keeping its assertion (exactly one `scrollToOffset` call with an offset above 2000 for the newest row): give `getState().data` real rows (`[{ kind: "user", id: "anchor" }, { kind: "work", id: "row-1", groupedEntries: [{ reasoning: { id: "thought-1" } }] }]`), make `sizeAtIndex` and `positionAtIndex` follow those two rows, and call `onReasoningRowToggle("thought-1")`. Add a table row to that test: a later `work` row exists after `thought-1`, so the toggled row is not the newest, and the expected reveal count is 0. Update the `MessagesTimeline.test.tsx` assertion to `expect(onReasoningRowToggle).toHaveBeenCalledWith(REASONING_ID)` for both toggles. -3. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx src/components/chat/MessagesTimeline.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: FAIL with the assertion message recorded in Task 5.1 step 9. -4. [ ] Implement the branch(es) named in Interfaces at the first wrong decision only, and add the `rowToggleNeedsEndReveal` cases (mode `anchoring-new-turn`, `scrollDeltaToRevealEnd: 0`, `toggledRowIsNewest` false gives false, true gives true) and `isNewestReasoningRow` cases (last `work` row before `working`: true; a `work` row followed by another `work` row: false; unknown id: false) to `timelineScrollAnchoring.test.tsx`. -5. [ ] Run the Done-when command. Expected: PASS, including the #173 test. -6. [ ] Prove the test can fail: revert this task's change by hand (git stash is not allowed; undo the edits in `ChatView.tsx`, `MessagesTimeline.tsx` and `timelineScrollAnchoring.ts`) and rerun the Done-when command. Expected: FAIL on the R12 test. Restore the change and run it again. Expected: PASS. -7. [ ] Review the changed callbacks and refs against `vercel-react-best-practices` (stable callbacks, the ref cleared on a timer that is cancelled on unmount, no state in a render path). Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. +1. [ ] Flip the test of Task 5.1 from `it.fails` to `it`. It is scenario (a) and the failing test of this task. +2. [ ] Add the failing test for scenario (b), titled with `R12 scroll path`, through the real toggle (same harness as Task 5.1 step 8): one work row whose `groupedEntries` hold `[thought A, tool B, thought C]`, mode `anchoring-new-turn`, the turn's end in view; clicking A's header leaves every `h.timelineList` scroll mock uncalled across three frames; clicking C's header produces exactly one `scrollToOffset` call with an offset above the old end. Keep the #173 test for scenario (c), with its fixture updated only as far as the fix's change to the callback requires (rows with ids in `getState().data`, the call carrying whatever the new contract takes), and its assertion unchanged: exactly one `scrollToOffset` call with an offset above 2000. +3. [ ] Run `vp test run --project unit src/components/ChatView.test.tsx -t "R12 scroll path"` from `apps/web`. Expected: FAIL on (a) and (b) with the assertion messages recorded in Task 5.1 step 9; (c) PASSES. +4. [ ] Implement the fix at the first wrong decision the Ruling names. Add unit cases next to any helper whose contract changed (`timelineScrollAnchoring.test.tsx` for `rowToggleNeedsEndReveal` and anything added beside it; `MessagesTimeline.logic.test.ts` for `resolveTimelineMaintainScrollAtEnd`), and update every doubled or asserted caller listed under Interfaces that the change touches. +5. [ ] Run the Done-when command. Expected: PASS, including (a), (b) and (c). +6. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed; undo the edits in the files you changed) and rerun the Done-when command. Expected: FAIL on (a) and (b). Restore the change and run it again. Expected: PASS. +7. [ ] Review the changed callbacks and refs against `vercel-react-best-practices` (stable callbacks, a ref cleared on a timer is cancelled on unmount, no state in a render path). Run `vp check` on the changed files and `vp run typecheck`. Expected: no errors. 8. [ ] State the profiler coverage line for the final report: none of child processes, RPC methods, worktree removal, binaries, shutdown, events or findings is touched. -### Task 5.3: Stop the Host dashboard values view re-rendering on the clock tick +### Task 5.3: Measure the Host dashboard values view's renders on the clock tick, and stop them only if there are any -- Satisfies: R18. Scenario pinned: Given an open dashboard whose reading has not changed, When the 1 s tick fires three times, Then the values view does not render again. +- Satisfies: R18 (Should, measure first). Scenario pinned: Given an open dashboard whose reading has not changed, When the 1 s tick fires three times, Then the values view does not render again. - Depends on: none -- Files: modify `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx`; test `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` -- Interfaces: consumes `HostDashboardValuesView({ values: HostDashboardValues; view: HostMetricsView | null; timestampFormat })`, whose only caller is `HostDashboardSheet` (``); `useVisibleSecondTick` is the only writer of the tick state (`nowMs`); `presentHostDashboard` builds a fresh `values` object and fresh `volumes`, `hiddenVolumes` and `statistics` arrays each render, so a reference check cannot work. Produces `const HostDashboardValuesView = memo(Inner, valuesViewPropsEqual)` where `valuesViewPropsEqual(previous, next): boolean` is true when `view` and `timestampFormat` are identical by reference and `values` is structurally equal. Search `apps/web/src` for an existing deep-equal helper first and use it; if none exists, write a local structural comparison in the sheet file. `values.since` carries the uptime text, so it changes on its own schedule and correctly lets the view render then. -- Skills: tdd (red-green for the render count); vercel-react-best-practices (memo comparator review) -- Read first: `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` `useVisibleSecondTick` and `HostDashboardValuesView`; `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` test "dims the values after 3 seconds without an item" (fake timer setup to copy) and its file-level mocks (`../ui/sheet`, `../ui/tooltip`, `~/state/query` and others, all kept); `apps/web/src/components/hostDashboard/HostMetricBars.tsx` (wrapped in `memo`, so it does not re-render when its props are unchanged and therefore cannot count the parent's renders) +- Files: modify (temporary probe, removed unless step 5 applies) `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx`; test `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` +- Interfaces: consumes `HostDashboardValuesView({ values: HostDashboardValues; view: HostMetricsView | null; timestampFormat })`, whose only caller is `HostDashboardSheet`; `useVisibleSecondTick` is the only writer of the tick state (`nowMs`); `presentHostDashboard` builds a fresh `values` object and fresh arrays each render. The real boundary is the React Compiler: the tests and the app build run through it (`docs/testing/README.md`), and it caches child JSX by input reference, so a counter placed on a child (`HostMetricBars`, which is also `memo`) cannot see whether the values view ran. The probe must measure the values view's own commit. Produces: either a logged measurement with no code change, or `memo(Inner, valuesViewPropsEqual)` plus a regression test. +- Skills: tdd (red-green, only if step 5 applies); vercel-react-best-practices (memo comparator review, only if step 5 applies) +- Read first: `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` `useVisibleSecondTick` and `HostDashboardValuesView`; `apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` test "dims the values after 3 seconds without an item" (fake timer setup to copy) and its file-level mocks (`../ui/sheet`, `../ui/tooltip`, `~/state/query` and others, all kept); `docs/testing/README.md` (the compiled test build) - Tier: standard - TDD: red-green -- Done when: from `apps/web`, `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0. +- Done when: from `apps/web`, `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx` exits 0, and the final report states the measured number of values-view commits across three ticks. Steps -1. [ ] In `HostDashboardSheet.test.tsx` add a file-level `vi.mock("./HostMetricBars", async (importOriginal) => ...)` that exports `HostMetricBars` as a plain, non-memoized function component which increments `h.barRenders` and renders the real memoized component with the same props. It must not be memoized: a plain wrapper renders whenever its parent, the values view, renders, which is what the test counts. Add `barRenders: 0` to the hoisted `h` object and reset it in `beforeEach`. This wraps and does not replace the real chart, so the other tests in the file still see its real output. -2. [ ] Write the failing test `does not render the values view again when only the clock tick changed`. Use the fake timers of the "dims the values" test (`vi.useFakeTimers({ now, toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"] })`; time is advanced with `vi.advanceTimersByTime` inside `act`). Render with `h.data = view()` (one stable object), record `h.barRenders`, advance 3 000 ms with the same data, and assert `h.barRenders` is unchanged and the text `Last updated 3 s ago` shows (so the sheet itself did render). -3. [ ] Run `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx -t "clock tick"` from `apps/web`. Expected: FAIL with `h.barRenders` higher than recorded. -4. [ ] Implement `valuesViewPropsEqual` and wrap `HostDashboardValuesView` in `memo` with it, in `HostDashboardSheet.tsx`. -5. [ ] Add `renders the values view again when a reading changes`: replace `h.data` with a view whose CPU differs, and assert `h.barRenders` rises and the new percent shows. Run the Done-when command. Expected: PASS. -6. [ ] Prove the test can fail: revert this task's change by hand (remove the `memo` wrapper) and rerun the Done-when command. Expected: FAIL on the clock tick test. Restore it and run again. Expected: PASS. -7. [ ] Review against `vercel-react-best-practices` (comparator cost is small next to a render; no inline object props added). Run `vp check apps/web/src/components/hostDashboard/HostDashboardSheet.tsx apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` and `vp run typecheck`. Expected: no errors. +1. [ ] Measure with a React Profiler `onRender` probe on the values view itself, under the compiled test build. Temporarily wrap the one call site in `HostDashboardSheet.tsx` as ` { (globalThis as { __r18?: string[] }).__r18?.push(phase); }}>`. A Profiler reports a commit only when something inside its subtree rendered, so it is not hidden by child-JSX caching. +2. [ ] In `HostDashboardSheet.test.tsx` write the probe test `measures the values view commits across three clock ticks`: set `globalThis.__r18 = []`, use the fake timers of the "dims the values" test (`vi.useFakeTimers({ now, toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"] })`; time advances with `vi.advanceTimersByTime` inside `act`), render with `h.data = view()` (one stable object), record `__r18.length`, advance 3 000 ms, and assert the sheet itself did render (the text `Last updated 3 s ago` shows). Print the delta with `console.info`. +3. [ ] Run `vp test run --project unit src/components/hostDashboard/HostDashboardSheet.test.tsx -t "values view commits"` from `apps/web`. Expected: PASS; read the delta. +4. [ ] If the delta is 0, the compiler already skips the values view on a tick: log "the compiler already skips it, delta 0" as a Ruling, remove the Profiler wrapper and the probe test by hand, run the Done-when command (Expected: PASS), and close the task with no code change. +5. [ ] Only if the delta is above 0: first write the regression test `does not render the values view again when its props are structurally equal`, which renders the exported `HostDashboardValuesView` under a test-owned `` from a parent that re-renders three times passing a fresh but structurally equal `values` object (and the same `view` and `timestampFormat`), and asserts the Profiler saw only the mount. This needs `HostDashboardValuesView` exported, so export it. Run it. Expected: FAIL (each re-render commits the view, because the compiler caches by reference and the objects are fresh). Then implement `valuesViewPropsEqual(previous, next): boolean` (true when `view` and `timestampFormat` are identical by reference and `values` is structurally equal; search `apps/web/src` for an existing deep-equal helper first; `values.since` carries the uptime text and correctly lets the view render when it changes) and wrap the component in `memo(Inner, valuesViewPropsEqual)`. Run it again. Expected: PASS. Add `renders the values view again when a reading changes` (same harness, a `values` with a different `cpu` string; the Profiler sees a second commit). Remove the Profiler wrapper from the sheet and the probe test of step 2; the sheet-level tick count is already logged in the Ruling. +6. [ ] Prove the test can fail (only if step 5 applied): revert this task's change by hand (remove the `memo` wrapper) and rerun the Done-when command. Expected: FAIL on the regression test. Restore it and run again. Expected: PASS. +7. [ ] Review against `vercel-react-best-practices` if code changed. Run `vp check apps/web/src/components/hostDashboard/HostDashboardSheet.tsx apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx` and `vp run typecheck`. Expected: no errors. Confirm `rg "r18-values|__r18" apps/web/src` finds nothing in `HostDashboardSheet.tsx`. 8. [ ] Log in the final report that no user-visible string, state or layout changed, so the `UI.md` review has nothing new to review. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md index 7e7b71dac..bb3d415f5 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-06-popups-picker-and-markdown-images.md @@ -11,6 +11,8 @@ Facts confirmed in source: the store files live in `apps/web/src/browser/` (not `apps/web/src/`). `ProviderModelPicker.tsx` lives in `apps/web/src/components/chat/`. `occlusionOwners` is a `ReadonlySet` in `BrowserSurfaceStoreState`; `browserSurfaceSync.ts` computes `occluded = state.occlusionOwners.size > 0`. `PopoverPopup` renders `PopoverPrimitive.Portal keepMounted`; `MenuPopup`, `SelectPopup`, `ComboboxPopup` and `AutocompletePopup` each render Portal, Positioner, Popup. `ModelPickerContent` keeps the search text in `useState("")` and stays mounted inside the keep-mounted popover. Existing DOM occlusion test to copy: `apps/web/src/components/ui/dialog.occlusion.dom.test.tsx` (it mocks `~/env` and the platform). Decision D11: tooltips take no lease and `tooltip.tsx` is not touched. +Test rules for every task (class C): each async wait is `vi.waitFor(..., { timeout: 30_000 })` or an explicit flush of a controlled queue (the stubbed frame queue, fake timers), never a yield loop. Boundaries not crossed by a unit test and not proved here: the native `setBounds` call that really hides the macOS preview (Task 6.1 and 6.2 stop at the `setBounds` mock of `browserSurfaceSync.test.ts`; the real overlap on macOS is unproven in this batch and is stated in the final report), and the signed asset URL the server issues for a workspace image (Task 6.4 uses the same `~/state/assets` mock as `RichMarkdownImage.test.tsx`). + ## Tasks ### Task 6.1: Occlusion leases that carry a rectangle diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md index dec8649dd..29f7eacb6 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-07-server-odds-and-e2e-harness.md @@ -45,7 +45,8 @@ Steps - Depends on: none - Files: modify `apps/desktop/e2e/wdio.conf.ts`; modify `apps/desktop/e2e/support/app-lifecycle.ts`; test `apps/desktop/e2e/support/app-lifecycle.test.ts` - Interfaces: consumes `recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise`, `ensureLiveDesktopUiWebview(): Promise`, `relaunchDesktopUiApplication(): Promise`, `stopRunningDesktopUiApplication(): Promise` and `waitForDesktopUiApplicationExit(pid: number, timeoutMs: number): Promise`. Callers of the functions that change: `ensureLiveDesktopUiWebview` is called from `wdio.conf.ts` hook `before`; `relaunchDesktopUiApplication` is also called, with no argument, from `apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts` (a spec this phase does not edit), so its new parameter must be optional; `stopRelaunchedDesktopUiApplication` is called from hook `after`. Produces, in `app-lifecycle.ts`: `DESKTOP_UI_TEST_TIMEOUT_MS = 120_000` (the value of `mochaOpts.timeout` in `wdio.conf.ts`, which now reads it); `DESKTOP_UI_RECOVERY_DEADLINE_MS = 100_000`; `interface RecoveryDeadline { readonly budgetMs: number; remainingMs(): number }`; `startRecoveryDeadline(budgetMs?: number): RecoveryDeadline` (default `DESKTOP_UI_RECOVERY_DEADLINE_MS`, measured with `Date.now()`); `withinRecoveryDeadline(deadline: RecoveryDeadline, step: string, operation: () => Promise): Promise`, which rejects with `Error("The webview recovery ran out of its ms during .")` when the remaining time reaches 0 before the operation settles, clears its timer when the operation settles first, and abandons (does not cancel) a still-running operation; `DeadWebviewRecovery.relaunch: (deadline: RecoveryDeadline) => Promise`; `recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery, deadline?: RecoveryDeadline): Promise`, which runs the probe, `isRunning`, `stopApplication` and `relaunch` each through `withinRecoveryDeadline` with the steps named `probe`, `liveness check`, `stop` and `relaunch`; `relaunchDesktopUiApplication(deadline?: RecoveryDeadline): Promise` (default `startRecoveryDeadline()`), whose exit wait, serve wait, `browser.reloadSession()`, `browser.tauri.restoreAllMocks()` and plugin wait each run through `withinRecoveryDeadline` and whose polling waits use `Math.min(, deadline.remainingMs())` (the existing 30 s exit cap and 90 s serve cap stay as upper bounds). The numbers: one deadline of 100 s covers the initial probe, the stop, the relaunch, `reloadSession` and the mock restore; the mocha timeout is 120 s, so 20 s remain for the rest of the hook (`resetDesktopUiConnectionCache`, `installDesktopUiMotionGuard`). WDIO lets a single command wait 120 s (`connectionRetryTimeout`), which is why every step is raced against the shared deadline instead of trusting the command's own timeout. This assumes WDIO runs `beforeTest` as a mocha hook under `mochaOpts.timeout`; if it does not, the deadline is still the bound that matters. -- Skills: tdd (red-green for the deadline and the hook) +- Failure evidence (the second entry point): after a failing test whose webview died while the native app survives, hook `afterTest` still calls `browser.saveScreenshot`, `browser.getPageSource` and reads the file, each of which throws against the dead webview; a throwing `afterTest` makes WDIO skip the remaining tests of the worker before `beforeTest` can run the probe (`docs/testing/cross-platform-validation.md` records this). Produces, in `app-lifecycle.ts`: `tolerateDeadDesktopUiWebview(operation: () => Promise): Promise`, which returns the operation's value, returns `undefined` when it rejects with an error for which `isDeadDesktopUiWebviewError(error)` is true, and rethrows every other error. `afterTest` in `wdio.conf.ts` runs its screenshot and page-source collection through it, so a recognized dead-webview error costs only the evidence and the test's own failure stands. +- Skills: tdd (red-green for the deadline, the hook and the evidence tolerance) - Read first: `apps/desktop/e2e/wdio.conf.ts` hooks `before` and `beforeTest`, and the comment on `desktopUiSpecs` (the nested array puts all shared specs in one worker, so `before` runs once for all of them); `apps/desktop/e2e/support/app-lifecycle.ts` `recoverDeadDesktopUiWebview`, `relaunchDesktopUiApplication`, `stopRunningDesktopUiApplication`, `ensureLiveDesktopUiWebview`; `apps/desktop/e2e/support/app-lifecycle.test.ts` describe `recoverDeadDesktopUiWebview` (its `deps` helper builds `probe`, `isRunning`, `stopApplication`, `relaunch` mocks; `relaunch` now receives a deadline); `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md`, `docs/testing/macos-desktop.md` - Tier: standard - TDD: red-green @@ -60,15 +61,18 @@ Steps - `an operation that settles first clears its timer`: `withinRecoveryDeadline(startRecoveryDeadline(1_000), "step", async () => 7)` resolves 7 and `vi.getTimerCount()` is 0. - `the recovery deadline leaves room in the test allowance`: `DESKTOP_UI_RECOVERY_DEADLINE_MS <= DESKTOP_UI_TEST_TIMEOUT_MS - 20_000`. - `relaunches again when the webview dies a second time in the same worker`: call `recoverDeadDesktopUiWebview` twice with a fresh `startRecoveryDeadline()` each; expect `relaunch` called twice and both results `true`. + - `failure evidence tolerates a dead webview`: `tolerateDeadDesktopUiWebview` over an operation rejecting with `new Error("Unsupported result type")` resolves `undefined`; over one rejecting with `new Error("boom")` it rejects with `boom`; over one resolving 7 it resolves 7. + - `the afterTest hook collects failure evidence through the tolerance`: with the same TypeScript AST parse of `wdio.conf.ts`, assert that the calls to `browser.saveScreenshot` and `browser.getPageSource` inside `afterTest` are arguments or descendants of a call to `tolerateDeadDesktopUiWebview`. - `the beforeTest hook registers the probe before any browser command`: read `apps/desktop/e2e/wdio.conf.ts` as text (do not import it: it throws without a packaged binary), parse it with the TypeScript compiler API (`ts.createSourceFile`, `typescript` is a root dev dependency), find the property assignment `beforeTest` in the exported `config`, and assert that the first statement of its body is the expression `await ensureLiveDesktopUiWebview()`. Add the same assertion for `before`. This test fails if the hook is omitted or moved after `resetDesktopUiConnectionCache`; calling the helper twice cannot show that. 2. [ ] Run `vp test run apps/desktop/e2e/support/app-lifecycle.test.ts` from the repository root. Expected: FAIL; the deadline tests on the missing exports, the hook test with `beforeTest` not starting with the probe. 3. [ ] In `app-lifecycle.ts` implement the exports above and thread the deadline through `recoverDeadDesktopUiWebview` and `relaunchDesktopUiApplication`. Update the existing tests' `deps` helper so `relaunch` accepts the deadline argument. Correct the comments: `ensureLiveDesktopUiWebview` is "the per-test probe of the shared app", and `recoverDeadDesktopUiWebview` runs "before a test", not "before a spec file". -4. [ ] In `wdio.conf.ts` call `await ensureLiveDesktopUiWebview()` as the first statement of `beforeTest`, before `resetDesktopUiConnectionCache`, read `mochaOpts.timeout` from `DESKTOP_UI_TEST_TIMEOUT_MS`, keep the call in `before` (the plugin-ready check needs a live webview at worker start) and fix its comment to say it covers worker start only. Add to the comment on `desktopUiSpecs` that the shared specs run in one worker, so `before` runs once for all of them and `beforeTest` carries the per-test probe. Expected: the change is in place, and the next command step checks it. +4. [ ] In `wdio.conf.ts` wrap the screenshot and page-source collection of `afterTest` in `tolerateDeadDesktopUiWebview`, and call `await ensureLiveDesktopUiWebview()` as the first statement of `beforeTest`, before `resetDesktopUiConnectionCache`, read `mochaOpts.timeout` from `DESKTOP_UI_TEST_TIMEOUT_MS`, keep the call in `before` (the plugin-ready check needs a live webview at worker start) and fix its comment to say it covers worker start only. Add to the comment on `desktopUiSpecs` that the shared specs run in one worker, so `before` runs once for all of them and `beforeTest` carries the per-test probe. Expected: the change is in place, and the next command step checks it. 5. [ ] Run the command of step 2. Expected: PASS. -6. [ ] Prove the tests can fail: revert this task's change to `wdio.conf.ts` by hand (remove the call from `beforeTest`) and rerun. Expected: FAIL on the hook test. Restore it. Then revert the `withinRecoveryDeadline` wrapping of the probe in `recoverDeadDesktopUiWebview` by hand and rerun. Expected: FAIL on `a probe that never answers fails at the deadline and relaunches nothing`. Restore it and rerun. Expected: PASS. +6. [ ] Prove the tests can fail: revert this task's change to `wdio.conf.ts` by hand (remove the call from `beforeTest` and the tolerance wrapper from `afterTest`) and rerun. Expected: FAIL on the hook tests. Restore it. Then revert the `withinRecoveryDeadline` wrapping of the probe in `recoverDeadDesktopUiWebview` by hand and rerun. Expected: FAIL on `a probe that never answers fails at the deadline and relaunches nothing`. Restore it and rerun. Expected: PASS. 7. [ ] Review `docs/testing/linux-desktop.md`, `docs/testing/windows-desktop.md` and `docs/testing/macos-desktop.md` for any statement about when the dead-webview probe runs or about the 30 s and 90 s waits; correct them in this change if they exist, and otherwise write "reviewed and remain accurate" in the final report. 8. [ ] Run `vp check apps/desktop/e2e/wdio.conf.ts apps/desktop/e2e/support/app-lifecycle.ts apps/desktop/e2e/support/app-lifecycle.test.ts` and `vp run typecheck` from the repository root. Expected: no errors. -9. [ ] Record in the final report that the real relaunch time is unmeasured; Phase 8's packaged runs record it, and a recovery that hits the 100 s deadline is a finding, not a reason to raise the deadline silently. +9. [ ] The proof that a test whose webview dies reaches recovery in its successor is Phase 8 Task 8.1 step 4, which kills the web process DURING a failing test (not between tests, which would skip the `afterTest` path). This phase's tests prove the pieces (the tolerance, the hook order, the deadline); Phase 8 proves the sequence. `docs/testing/cross-platform-validation.md` states that a failing `afterTest` skips later tests; Phase 9 owns correcting that sentence, so report it there. +10. [ ] Record in the final report that the real relaunch time is unmeasured; Phase 8's packaged runs record it, and a recovery that hits the 100 s deadline is a finding, not a reason to raise the deadline silently. ### Task 7.3: Run the flaky hint test 50 times under CPU load and diagnose any failure @@ -108,17 +112,17 @@ Steps Steps -1. [ ] In each `tests` module add the capture writer (copy of `TraceCapture` and `TraceCaptureWriter`), installed with `tracing_subscriber::fmt().with_writer(capture).with_ansi(false).with_max_level(tracing::Level::DEBUG)` through `tracing::subscriber::with_default`, so a `debug` line is also seen. +1. [ ] In each `tests` module add the capture writer (copy of `TraceCapture` and `TraceCaptureWriter`), installed with `tracing_subscriber::fmt().with_writer(capture).with_ansi(false).with_max_level(tracing::Level::DEBUG)` through `tracing::subscriber::set_default`, so a `debug` line is also seen. 2. [ ] In `http.rs` write the failing test `a_rejected_pairing_offer_is_logged_at_warn_with_its_reason_and_without_the_token`. Call `validate_pairing_offer_input("Test Device", "ftp://example.test/?token=pairing-secret-marker", "another-device", None)`, pass `&error.to_string()` of its `Err` to `invalid_pairing_offer_response` as the handler does, and assert the captured text contains `WARN`, `endpoint must be an http(s) URL` and `trace_id=`, and does not contain `pairing-secret-marker`. Add the case `invalid_pairing_offer_response("idempotency key is invalid")` expecting `WARN`. Assert the status is `400` and the JSON `reason` is still `invalid_pairing_offer`. -3. [ ] In `service.rs` write the failing tests `a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential_persisted` and `..._in_memory` (the `a_refused_pairing_credential` prefix is what the selector matches). Credentials are obtained from `PairingCredentialResult.credential` (`issue_pairing(scopes, label)`, `issue_pairing_with_proof(scopes, label, thumbprint)`); an expired link comes from `issue_pairing_for_subject(scopes, label, "one-time-token", -1, AuthGrantMetadata::default(), None)` (an `expires_at` already in the past, so no sleeping and no clock control; wrap the returned `PairingIssuance::Reserved` the way `reserved_pairing` does; if the table refuses an expiry before creation, insert the row with `Repositories::create_auth_pairing_link` and a past `expires_at`). Every credential text starts with `pairing-secret-marker`. Persisted service: build it like `mcp_test_service()` (in-memory SQLite, migrations, `AuthService::new_with_persistence`). In-memory service: `AuthService::new(&config, vec![7_u8; 32])`. Cases and the class each must log: +3. [ ] In `service.rs` write the failing tests `a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential_persisted` and `..._in_memory` (the `a_refused_pairing_credential` prefix is what the selector matches). Credentials are obtained from `PairingCredentialResult.credential` (`issue_pairing(scopes, label)`, `issue_pairing_with_proof(scopes, label, thumbprint)`); an expired link comes from `issue_pairing_for_subject(scopes, label, "one-time-token", -1, AuthGrantMetadata::default(), None)` (an `expires_at` already in the past, so no sleeping and no clock control; wrap the returned `PairingIssuance::Reserved` the way `reserved_pairing` does; if the table refuses an expiry before creation, insert the row with `Repositories::create_auth_pairing_link` and a past `expires_at`). The test collects every credential the issuance calls return (`PairingCredentialResult.credential`, generated internally) in a list, and also the text it presents for a refused call. Persisted service: build it like `mcp_test_service()` (in-memory SQLite, migrations, `AuthService::new_with_persistence`). In-memory service: `AuthService::new(&config, vec![7_u8; 32])`. Cases and the class each must log: - Persisted: `unknown` (a credential never issued), `empty` (`""`), `revoked` (`revoke_pairing(id)` then consume), `already_used` (consume twice), `expired` (the `-1` ttl link), `proof_key_mismatch` (`issue_pairing_with_proof` with one thumbprint, consumed with another). - In-memory: `unknown` (never issued), `empty`, `unknown` for a revoked link (assert the class is `unknown`, not `revoked`, and say why in a comment: `revoke_pairing` removes the record), `already_used` (consume twice with no issue or list call in between), `expired` (the `-1` ttl link, consumed before any issue or list call), `proof_key_mismatch`, and `desktop_bootstrap_expired` (set `service.desktop_bootstrap = Some(DesktopBootstrap { credential: "pairing-secret-marker-desktop".into(), expires_at_ms: 0 })` on the owned service before use; the field is private to the module and the `tests` module is its child). - For each case install the capture of step 1 with `tracing::subscriber::with_default` around the awaited `consume_grant` call (a synchronous guard held across the await in a current-thread `#[tokio::test]`), assert the captured text has exactly one `WARN` line containing `class=` and `a pairing credential was refused`, does not contain `pairing-secret-marker`, and that the call returned `Err(AuthError::InvalidCredential)`. The capture is read after the call settles, so nothing is pending when asserting. + For each case install the capture of step 1 with `let _guard = tracing::subscriber::set_default(subscriber);` at the top of a current-thread `#[tokio::test]` (the default flavor), the guard held across every await as `provider_terminal_supervisor.rs` does at its `set_default` use, so the awaited `consume_grant` runs under it. Assert the captured text has exactly one `WARN` line containing `class=` and `a pairing credential was refused`, contains none of the collected issued credentials and not the presented text (loop over the list, one assertion each), and that the call returned `Err(AuthError::InvalidCredential)`. The capture is read after the call settles, so nothing is pending when asserting. 4. [ ] Run `cargo test -p bibcode-server --lib auth::http::tests::a_rejected_pairing_offer_is_logged_at_warn` and `cargo test -p bibcode-server --lib auth::service::tests::a_refused_pairing_credential`. Expected: FAIL; the first because the line is `DEBUG`, the second because no `WARN` line exists. 5. [ ] Change `tracing::debug!` to `tracing::warn!` in `invalid_pairing_offer_response`, nothing else there. 6. [ ] In `service.rs` add `pairing_rejection_class`, and log through it at the three refusal sites of `consume_grant` (desktop bootstrap expired, persisted `None` after the extra read, in-memory refusal). On the persisted path the extra read runs only when the update returned `None`; if it errors, use `unclassified`. Do not log the credential, a hash of it, the link id, the label, the subject, an address or a path. 7. [ ] Run the two commands of step 4. Expected: PASS. -8. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed): change `warn!` back to `debug!` in `invalid_pairing_offer_response` and remove the `warn!` call in `consume_grant`, then run the two commands of step 4. Expected: FAIL in both. Restore the change and run them again. Expected: PASS. Separately put the credential text into the `warn!` call temporarily and run the service test. Expected: FAIL on the "never the credential" assertion; remove it. +8. [ ] Prove the tests can fail: revert this task's change by hand (git stash is not allowed): change `warn!` back to `debug!` in `invalid_pairing_offer_response` and remove the `warn!` call in `consume_grant`, then run the two commands of step 4. Expected: FAIL in both. Restore the change and run them again. Expected: PASS. Separately put the issued credential text into the `warn!` call temporarily and run the service test. Expected: FAIL on the absence assertion for that credential; remove it. 9. [ ] Run `cargo test -p bibcode-server --test auth_http` (response bodies unchanged). Expected: PASS. 10. [ ] Run `cargo fmt --all --check` and `cargo clippy -p bibcode-server --all-targets -- -D warnings`. Expected: no errors. 11. [ ] Profiler coverage line for the final report: refusals are logged through `tracing::warn!` at an operational boundary, as `AGENTS.md` asks; nothing goes through `profiler::emit` or `record_finding`; the class is a fixed token and no content, token, hash, device name, address or path is logged. Note the cost: one extra indexed read on a refused credential, which an unauthenticated caller can trigger, so keep it a single query and add no retry. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md index be75712a2..8b42af0b6 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/phases/PHASE-08-labs-and-probes.md @@ -30,7 +30,7 @@ Steps 1. [ ] Run `df -h /` and `docker ps --filter name=bibcode-linux-lab`. Expected: at least 60 GB free and the container listed. If the image or container is absent, stop and report: its Dockerfile was not kept, and rebuilding it is a decision for the user. 2. [ ] Copy the branch head into the container as a `git archive` and build the packaged app with `CARGO_BUILD_JOBS=4`. Expected: an AppImage for the branch head exists. 3. [ ] Run the packaged default list under `xvfb-run` with the Profiler on and `GST_PLUGIN_PATH` set as the runbook says. Expected: every spec passes, including `chat-reasoning-rows` (the harness sets a 1000x720 viewport itself); a failure is diagnosed, not rerun blindly. -4. [ ] R15: start a run of two shared specs and, between the first and the second test, end the web process of the app by its PID (found as the child of the app's own PID, never by name). Expected: the first test after the kill relaunches the app once inside its time allowance and passes or fails on its own assertions, and the tests after it run on the new app; before Task 7.2's change the same scenario fails every later shared spec. +4. [ ] R15: start a run of two shared specs and end the web process of the app by its PID (found as the child of the app's own PID, never by name) while a test is running, so that test fails and its `afterTest` evidence collection meets a dead webview. Expected: `afterTest` does not throw, the next test's `beforeTest` relaunches the app once inside its time allowance, and that test and the ones after it run on the new app; before Task 7.2's change the same scenario skips or fails every later shared spec. 5. [ ] R35: with unsaved Files edits and an allowed remote install simulated by the harness's update mock, reload the page, then end the web process by its PID so the app starts a new one. Expected: after each, the installing dialog is shown and saving is held, as Task 4.2's Interfaces state; if the marker does not survive the relaunched web process, reopen Task 4.2 with that result. 6. [ ] R20: in one packaged run, switch between Settings and a thread ten times, then read the profiler data. Expected: record the longest main-thread stall and the web process's memory at start and end. A `client.stall`, `memory.leak-suspect` or `memory.leak-likely` finding that carries a trace opens a diagnosis task in the phase that owns the traced code; with no such finding, log "no finding with a trace; nothing changed". 7. [ ] Log every profiler finding of the runs with its class (caused by this change, pre-existing, test artifact). diff --git a/docs/plans/2026-11-13-issue-batch-348-358/plan.md b/docs/plans/2026-11-13-issue-batch-348-358/plan.md index e19f90b7f..a76081075 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/plan.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/plan.md @@ -1,7 +1,7 @@ # Issue batch #348 and #351 to #358: terminal panel, Git, worktree, update guard and web follow-ups -Status: Draft -Spec: spec.md (Status line: Draft, sha256 3b06d6886431) +Status: Approved 2026-11-13 (the user's standing pre-approval of 2026-10-30, after two Codex reviews and the advisor's; see reviews/) +Spec: spec.md (Status line: Approved 2026-11-13, sha256 be2ccd2fab22) Repository: this repository Base: main @@ -77,7 +77,7 @@ Phases in the same round run in parallel. A phase depends only on phases in earl |---|---|---|---| | `apps/web/src/components/ThreadTerminalPanel.tsx` and `ThreadTerminalPanel.interactions.test.tsx` | modify | 1 | from `apps/web`: `vp test run --project unit src/components/ThreadTerminalPanel.interactions.test.tsx` | | `apps/web/src/components/terminalSizeDiagnostics.ts` and its test | create | 1 | same command with the new test path | -| `apps/web/src/components/terminalSizing.ts`, `terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts` | modify (only if a task needs it) | 1 | their existing tests | +| `apps/web/src/components/terminalSizing.ts` (re-measure helper) and a new `terminalSizing.xterm.test.ts`; `terminalSizePolicy.ts`, `apps/web/src/lib/terminalFont.ts` (only if a task needs it) | modify | 1 | their existing tests | | `apps/web/package.json` (dependency `@xterm/addon-unicode11` 0.9.0) | modify | 1 | `vp install`, then the R5 xterm test | | `packages/contracts/src/environment.ts`, `packages/contracts/fixtures/**`, `packages/shared/src/testSupport.ts` | modify | 1 | `vp run check:contracts` | | `apps/server/src/lifecycle.rs`, `apps/server/src/production/control.rs` | modify | 1 | `cargo test -p bibcode-server --lib lifecycle` | @@ -86,7 +86,7 @@ Phases in the same round run in parallel. A phase depends only on phases in earl | `apps/server/src/git/broadcaster.rs`, `apps/server/src/production/git_vcs.rs`, `apps/server/src/production/runtime.rs` | modify | 2 | Phase 2 checkpoint | | `apps/server/src/production/worktree_catalog_rpc.rs`, `apps/server/src/diagnostics/profiler/worktree_audit.rs` | modify | 3 | `cargo test -p bibcode-server --lib worktree` | | `apps/server/src/persistence/repositories.rs`, `apps/server/tests/repositories.rs`, `apps/server/src/production/orchestration_effects.rs`, `apps/server/tests/profiler_worktree_audit.rs`, `apps/server/src/mcp/tools/worktrees.rs` | modify | 3 | `cargo test -p bibcode-server --test repositories` and `--test profiler_worktree_audit` | -| `apps/web/src/components/files/unsavedFileEdits.ts`, `UnsavedChangesCloseDialog.tsx` and their tests; `apps/web/src/components/RemoteUpdateRequestNotifier.tsx`, `apps/web/src/routes/__root.tsx` and their tests (only if Task 4.4 finds the action unreachable) | modify | 4 | Phase 4 checkpoint | +| `apps/web/src/components/files/unsavedFileEdits.ts`, `UnsavedChangesCloseDialog.tsx` and their tests; `apps/web/src/tauriDesktopBridge.ts` (the `getUpdateState` entry) and its test | modify | 4 | Phase 4 checkpoint | | `scripts/desktop-event-names.test.ts` | create | 4 | `vp test run scripts/desktop-event-names.test.ts` | | `apps/web/src/components/ChatView.tsx` and `ChatView.test.tsx`; `apps/web/src/components/chat/timelineScrollAnchoring.ts`, `MessagesTimeline.logic.ts`, `MessagesTimeline.tsx` and their tests | modify | 5 | Phase 5 checkpoint | | `apps/web/src/components/hostDashboard/HostDashboardSheet.tsx` and its test | modify | 5 | Phase 5 checkpoint | diff --git a/docs/plans/2026-11-13-issue-batch-348-358/progress.md b/docs/plans/2026-11-13-issue-batch-348-358/progress.md index 3de57a59e..c60b421fe 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/progress.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/progress.md @@ -1,9 +1,9 @@ # Progress: Issue batch #348 and #351 to #358 Plan: plan.md -Spec: spec.md (sha256 3b06d6886431) -Plan status: Draft -Plan review: Codex gpt-6.1-sol (effort high), round 1, needs rework; 22 findings handled, see reviews/plan-review-1.md +Spec: spec.md (sha256 be2ccd2fab22) +Plan status: Approved 2026-11-13 +Plan review: Codex gpt-6.1-sol (effort high), round 2, needs rework; all 13 findings fixed without a third round, see reviews/plan-review-2.md Executor: none Branch: claude/open-issues-pr-dc3c33 @@ -38,18 +38,18 @@ Branch: claude/open-issues-pr-dc3c33 | 4.4 | Decide whether the dialog hides the Manage devices toast | 4 | Open | | | | 4.5 | Fail when a desktop event name exists on one side only | 4 | Open | | | | 5.1 | Diagnose which path scrolls to the end after a programmatic scroll and a toggle | 5 | Open | | | -| 5.2 | Fix the first wrong decision the diagnosis names | 5 | Open | | | -| 5.3 | Stop the Host dashboard values view re-rendering on the clock tick | 5 | Open | | | +| 5.2 | Fix the first wrong decision the Ruling names | 5 | Open | | | +| 5.3 | Measure the Host dashboard values view's renders on the clock tick, and stop them only if there are any | 5 | Open | | | | 6.1 | Occlusion leases that carry a rectangle | 6 | Open | | | | 6.2 | Popups hold a rectangle lease only while open | 6 | Open | | | | 6.3 | The model picker reopens with an empty search | 6 | Open | | | | 6.4 | Read-only Markdown loads relative images from the workspace | 6 | Open | | | | 7.1 | Hide any read-only FUSE mount under a `.mount_` directory | 7 | Open | | | -| 7.2 | Check for a dead webview before every test and keep the relaunch inside the test allowance | 7 | Open | | | +| 7.2 | Check for a dead webview before every test and keep the whole recovery inside one deadline | 7 | Open | | | | 7.3 | Run the flaky hint test 50 times under CPU load and diagnose any failure | 7 | Open | | | | 7.4 | Log a rejected pairing offer and a rejected pairing credential at `warn` with a fixed class | 7 | Open | | | -| 8.1 | Linux lab run of the packaged list, with the two discriminating runs | 8 | Open | | | -| 8.2 | Mixed-version probe against the previous release's server | 8 | Open | | | +| 8.1 | Linux lab run of the packaged list, with the webview-kill and route-change probes | 8 | Open | | | +| 8.2 | Mixed-version probe against the latest released server | 8 | Open | | | | 8.3 | Windows VM runs | 8 | Open | | | | 8.4 | Load runs on the Mac | 8 | Open | | | | 9.1 | Profiler and validation references | 9 | Open | | | @@ -60,3 +60,4 @@ Branch: claude/open-issues-pr-dc3c33 ## Log - 2026-11-13 Plan written. Sources: ten issues exported to `sources/`, `evidence.md`, `triage.md`. #350 moved to `../2026-11-13-terminal-screen-350/` after its design review returned "needs rework". - 2026-11-13 Baseline for Task 5.1 (plan decision P3): the packaged harness sets a 1000x720 viewport itself, and `chat-reasoning-rows` passed on Linux WebKitGTK in two full lab runs of the build that became `main` (31 passing each). No reproduction on Linux; the Windows VM (WebView2, device pixel ratio 2) is the signal. +- 2026-11-13 Plan approved under the user's standing pre-approval (two Codex reviews and the advisor's; 22 and 13 findings handled). diff --git a/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-2.md b/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-2.md new file mode 100644 index 000000000..54ce197b1 --- /dev/null +++ b/docs/plans/2026-11-13-issue-batch-348-358/reviews/plan-review-2.md @@ -0,0 +1,107 @@ +Verdict: **Needs rework.** All 41 active requirements have task assignments, but the revised plan still misses real caller behavior in the update guard and crash recovery. Several new test recipes would either pass without their change, remain red afterward, or hang. + +Coverage: **requirement → tasks** + +“Partial” identifies an incomplete scenario or proof. Mapping checked against `plan.md:108`; plan-file locations below are relative to `docs/plans/2026-11-13-issue-batch-348-358/`. + +| Requirement | Tasks | Coverage | +|---|---|---| +| R1 | 1.1 | **Partial:** actual font measurement missing | +| R2 | 1.2 | Covered | +| R3 | 1.3 | Covered | +| R4 | 1.4 | Covered | +| R5 | 1.10, 9.2 | Covered | +| R6–R11 | — | Withdrawn; outside scope | +| R12 | 5.1, 5.2, 8.3; 8.1 regression guard | **Partial:** H3 proof and grouped-entry handling | +| R13 | 3.1, 8.3, 8.4 | Covered | +| R14 | 7.1 | Covered | +| R15 | 7.2, 8.1 | **Partial:** preceding failure hook can prevent recovery | +| R16 | 1.8 | Assigned conditional test/fix | +| R17 | 1.5 | Covered | +| R18 | 5.3 | **Proof gap:** counter cannot detect the parent render | +| R19 | 8.2 | Assigned probe | +| R20 | 8.1 | Assigned bounded probe | +| R21 | 2.1 | Covered | +| R22 | 2.2 | Covered | +| R23 | 2.3 | Covered | +| R24 | 9.1 | Covered | +| R25 | 9.1 | Covered | +| R26 | 2.4, 9.1 | **Partial:** concurrent admission and runtime wiring proof | +| R27 | 2.5 | **Proof gap:** cancellation regression can hang | +| R28 | 2.6 | Covered, including late refresh failure | +| R29 | 2.7 | Covered | +| R30 | 2.8 | Assigned conditional test/fix | +| R31 | 3.2 | Covered | +| R32 | 3.3, 9.1 | Covered | +| R33 | 3.4 | Covered | +| R34 | 3.5 | Covered | +| R35 | 4.2, 8.1 | **Partial:** real bridge converts failure into an apparent answer | +| R36 | 4.3 | **Partial:** same bridge problem | +| R37 | 4.1 | Covered | +| R38 | 4.5 | Covered | +| R39 | 4.4 | **Partial:** destination remains behind the installing modal | +| R40 | 6.1, 6.2 | Covered | +| R41 | 1.6, 9.2 | Covered | +| R42 | 1.7 | Covered | +| R43 | 1.9 | Assigned conditional test/fix | +| R44 | 7.3 | Assigned loaded runs and conditional diagnosis | +| R45 | 7.4 | **Proof gap:** log-capture and credential assertions | +| R46 | 6.3 | Covered | +| R47 | 6.4 | Covered | + +No active requirement is wholly unassigned. No task is unrelated; Tasks 9.3 and 9.4 support verification and delivery. + +Findings, most severe first: + +| # | Severity | Location (file:line) | Issue | Evidence | Fix | +|---|---|---|---|---|---| +| 1 | Major | `phases/PHASE-04-update-install-guard.md:42` | **Round 1 #1 is not fully fixed through the real bridge.** A failed query can still release the restored block. | `getUpdateState` uses `tauriInvokeOr` (`apps/web/src/tauriDesktopBridge.ts:657`), which catches every rejection and returns its fallback (`:127`). That fallback has `requestedBy: null` (`:439`). The proposed fake-bridge rejection tests bypass this conversion. | Own the bridge and its tests. Preserve failure for supported state queries; distinguish genuine unsupported-query compatibility from operational failure. Test native invocation rejection through the real bridge. | +| 2 | Major | `phases/PHASE-01-terminal-panel-fixes.md:74` | The font listener repaints but does not reliably re-measure glyph metrics. | `scheduleResize` calls sizing and then atlas clearing/refresh (`apps/web/src/components/ThreadTerminalPanel.tsx:2834`). FitAddon reads cached cell dimensions (`apps/web/node_modules/@xterm/addon-fit/src/FitAddon.ts:62`); atlas clearing does not measure characters (`apps/web/node_modules/@xterm/xterm/src/browser/services/RenderService.ts:258`). The proposed test changes the mocked proposal directly (`PHASE-01:63`), bypassing the missing measurement. | Explicitly invalidate/re-measure font metrics before proposing dimensions, while preserving mirror geometry. Add a real-xterm measurement regression with unchanged mount dimensions. | +| 3 | Major | `phases/PHASE-02-git-and-pull-request-lookups.md:85` | Shutdown still has an admission-versus-registration race. | The proposed shutdown cancels/closes/waits outside `State`’s mutex. An admission can pass its closed check, pause before `tasks.spawn`, and register after shutdown observed an empty tracker. Current admission performs loading and spawning later in its critical section (`apps/server/src/source_control/open_pull_request_lookups.rs:181`, `:219`). The repository already solves this pattern by registering a tracking token under the admission lock (`apps/server/src/git/process.rs:845`). | Serialize closure of admission with task/token registration. Add a multithreaded gated test that pauses admission before registration and proves shutdown cannot finish early. | +| 4 | Major | `phases/PHASE-07-server-odds-and-e2e-harness.md:66` | The next `beforeTest` may never reach the new recovery probe after an actual test crashes its webview. | When the native app survives, `afterTest` still calls screenshot and page-source commands against the dead webview (`apps/desktop/e2e/wdio.conf.ts:211`). The runbook explicitly records that this hook failure causes subsequent tests to be skipped (`docs/testing/cross-platform-validation.md:673`). Task 8.1 kills the process **between** tests (`PHASE-08:33`), avoiding this failure path. | Make failure-evidence collection tolerate recognized dead-webview errors. Inject the crash during a failing test and prove its successor reaches recovery. | +| 5 | Major | `phases/PHASE-04-update-install-guard.md:56` | Task 4.2 cannot satisfy its own completion gate before Task 4.3. | Its test requires “Check again,” but Task 4.2 owns no dialog implementation (`:41`). Task 4.3 depends on 4.2 and adds that button (`:68`, `:82`). The existing dialog contains no button (`apps/web/src/components/files/UnsavedChangesCloseDialog.tsx:91`). | Move button assertions into 4.3, or deliver the button and its implementation together in 4.2. | +| 6 | Major | `phases/PHASE-05-reasoning-row-scroll-and-dashboard.md:47` | The new H3 test cannot turn green through the prescribed H3 fix. | H3 tests `MessagesTimeline` directly, while its fix changes `ChatView` state (`:59`). The timeline derives maintenance from its supplied `followsEnd` prop (`apps/web/src/components/chat/MessagesTimeline.tsx:754`); its standalone fixture’s toggle callback does nothing (`MessagesTimeline.test.tsx:248`). | Exercise the real `ChatView` toggle-to-prop path, exposing the resulting LegendList maintenance prop in that harness. | +| 7 | Major | `phases/PHASE-05-reasoning-row-scroll-and-dashboard.md:91` | The replacement render counter still does not prove R18. | The values component memoizes chart arrays and passes them to `HostMetricBars` (`apps/web/src/components/hostDashboard/HostDashboardSheet.tsx:222`, `:247`). An in-memory transform with the installed React Compiler confirmed that those JSX elements are cached by their chart-array references. A non-memoized mock wrapper therefore need not render when the values component renders. These tests run compiled (`docs/testing/README.md:224`). | Measure execution of the values component itself, or use a probe whose invocation cannot be hidden by child-JSX caching. Demonstrate failure before adding `memo` and after removing it. | +| 8 | Major | `phases/PHASE-05-reasoning-row-scroll-and-dashboard.md:57` | The proposed newest-thought predicate mistakes a grouped row for an individual reasoning entry. | Several consecutive work entries share one list row (`apps/web/src/components/chat/MessagesTimeline.logic.ts:1033`), and each reasoning entry renders its own toggle (`MessagesTimeline.tsx:1838`). In `[thought A, tool B, thought C]` inside the final work row, the proposed predicate considers A newest because no later **list row** exists. | Check entries following the reasoning ID within its group as well as subsequent rows. Add a grouped-entry case that preserves A’s header while retaining the newest-thought reveal. | +| 9 | Minor | `phases/PHASE-02-git-and-pull-request-lookups.md:112` | The “wiring test” does not test runtime wiring and does not retain the late refresh as claimed. | It invokes the service shutdown directly, without `ProductionRuntime::quiesce_for_update`. It also creates no subscriber, so the late future is dropped by `spawn_for_subscribers` (`apps/server/src/git/broadcaster.rs:943`). Removing the runtime shutdown call would leave this test passing. | Retain a subscriber and assert the child is alive before shutdown. Add a runtime-level test that fails when its lookup-shutdown call alone is removed. | +| 10 | Minor | `phases/PHASE-02-git-and-pull-request-lookups.md:135` | The cancellation regression waits forever for the behavior it should assert. | A wrongly stored failure leaves one entry, so the proposed `yield_now` loop never terminates. The current loader’s cancellation counter advances before cache settlement (`apps/server/src/source_control/open_pull_request_lookups.rs:366`); neither that counter nor entry removal is an independent completion signal. | Signal flight settlement separately, await it, then assert the entry count and retry behavior. Bound failed synchronization without relying on paused-clock auto-advance during a busy yield loop. | +| 11 | Minor | `phases/PHASE-07-server-odds-and-e2e-harness.md:113` | The pairing-log tests cannot provide their promised secret-leak proof as written. | Issuance generates credentials internally (`apps/server/src/auth/service.rs:1653`); it does not supply the promised marker prefix. Checking only that marker can miss logging the actual issued credential. Also, `with_default` is prescribed as an await-spanning guard (`PHASE-07:116`), whereas the cited existing harness uses `set_default` (`apps/server/tests/provider_terminal_supervisor.rs:4380`). | Assert absence of each actual issued credential. Use an await-spanning `set_default` guard on the current-thread test, or instrument the future with its subscriber. | +| 12 | Minor | `phases/PHASE-04-update-install-guard.md:104` | Navigating to Manage devices leaves its destination behind the installing modal. | The host persists beside the authenticated route outlet (`apps/web/src/routes/__root.tsx:229`), and continues rendering an always-open installing alert dialog while the install block exists (`apps/web/src/components/files/UnsavedChangesCloseDialog.tsx:93`, `:127`). Calling navigation changes the page beneath it; the proposed callback-only tests cannot prove device controls are usable. | Make device management accessible above/from the installing dialog while retaining Files save holds. Test interaction with the destination, not only callback invocation. | +| 13 | Nit | `progress.md:48` | Reworked task names are inconsistent, and one phase reference is stale. | Titles for 7.2, 8.1 and 8.2 differ from their phase files (`PHASE-07:42`; `PHASE-08:16`, `:38`; `progress.md:48`, `:51`, `:52`). Task 3.3 still says “Phase 13” documents its strings (`PHASE-03:74`). | Synchronize the three titles and change the documentation reference to Phase 9. | + +What the plan got right: + +- Preserve the resizing fit mock and mirror assertions in Task 1.1; the measurement fix must retain them (`PHASE-01:57`). +- Unicode 11 activation, `allowProposedApi`, and the real-xterm cursor test are coherent. Server width parity remains explicitly outside this plan (`PHASE-01:393`; `spec.md:96`). +- The late-failure callback, both hand-over gates, controlled picker reopening, popup movement tracking, and both rollback audit assertions are now explicitly covered (`PHASE-02:175`; `PHASE-01:232`; `PHASE-06:43`, `:65`; `PHASE-03:94`). +- The deliberate failure-cache and AppImage test rewrites match the binding spec. They should remain (`PHASE-02:139`; `PHASE-07:36`). +- Declared Round 1 source ownership has no direct collision: contracts/fixtures and the web manifest belong to Phase 1, popup primitives to Phase 6, and lockfile writes to the executor (`plan.md:81`, `:82`, `:94`, `:103`). No additional skill or task-size blocker found. + +Open questions the plan cannot answer: + +- Does the session marker survive the actual Linux web-process recovery? Task 8.1 correctly leaves this for native evidence (`PHASE-08:34`). +- Which R12 path actually scrolls on Windows? The live trace must still decide that before implementation (`PHASE-05:43`). +- After recovery times out, how will still-running operations be prevented from affecting subsequent tests? The new deadline explicitly abandons operations without cancelling them (`PHASE-07:47`). + +Read-only verification included source tracing, spec-hash and task/title comparisons, and an in-memory compiler transform. No builds, suites, native runs, or file edits were performed; final Git checks preserved the initial state. Profiler review covered lookup shutdown ordering, rollback outcomes, and warning-log privacy; no profiler runtime evidence was collected. + + + +## Handling + +All 13 findings were accepted after a triage with the advisor; none contradicts a decision of the spec or the user. This was the second and last review round, so the fixes were not sent back for a third review. Recipe faults that remain will show at each task's red step and are logged as Rulings during execution. + +- 1 (R35, Tasks 4.2, 4.3): fixed. Phase 4 now owns the `getUpdateState` entry of the web bridge: a rejection other than "the desktop does not know this command" is rethrown instead of becoming a fallback answer, the other callers are listed, and a test goes through the real bridge function. +- 2 (R1, Tasks 1.1, 1.2): fixed. Neither the new listener nor the existing bundled-font effect made xterm measure the font again. A helper re-measures the cell size before any size is proposed, a mirror keeps its grid, and a real-xterm test with an unchanged mount proves it. Spec R1 gained that scenario. +- 3 (Task 2.4): fixed. Admission closes and registers its tracking token under the same lock; a multithreaded gated test proves shutdown cannot finish early. +- 4 (Task 7.2): fixed. Failure-evidence collection tolerates a dead webview; the lab step (Task 8.1) now ends the web process during a failing test. +- 5 (Tasks 4.2, 4.3): fixed. The button assertions moved to Task 4.3. +- 6, 8 (Tasks 5.1, 5.2): fixed. The diagnosis ends at a logged verdict with one failing test through the real toggle path; the pre-written fix branches are gone; the grouped-row case is a spec scenario of R12. +- 7 (Task 5.3): fixed. The task measures the values view with a Profiler probe under the compiled build first; with no render on a tick it closes with no change. Spec R18 says so. +- 9, 10 (Phase 2): fixed. A runtime-level quiesce test fails when the shutdown call is removed; the cancellation test waits on an explicit settlement signal with a bounded wait. +- 11 (Task 7.4): fixed. Tests assert the absence of each issued credential under a guard that spans awaits. +- 12 (R39, Task 4.4): fixed. The task ends at the DOM test's verdict; the requirement for the defect case is in the spec (device management usable while the dialog is up). +- 13: fixed. Progress titles are regenerated from the phase files; the stale phase reference is corrected. +- Open questions: the marker's survival across a web-process relaunch and the scrolling path on Windows stay with Tasks 8.1 and 8.3, as the review agrees. Operations abandoned by the recovery deadline are not cancelled; Task 7.2 states it and the next probe detects a dead session. + +Two rules were applied to every phase file beyond the listed findings: each Must task names the real boundary its behavior crosses and its test crosses it or names the lab step that does; and a diagnosis or decide-by-test task ends at a logged verdict, with any fix opened as a new task. diff --git a/docs/plans/2026-11-13-issue-batch-348-358/spec.md b/docs/plans/2026-11-13-issue-batch-348-358/spec.md index 3770b8c34..a45233205 100644 --- a/docs/plans/2026-11-13-issue-batch-348-358/spec.md +++ b/docs/plans/2026-11-13-issue-batch-348-358/spec.md @@ -1,6 +1,6 @@ # Issue batch: terminal fidelity, Git and worktree follow-ups, update guard and web fixes -Status: Draft +Status: Approved 2026-11-13 (the user's standing pre-approval of 2026-10-30: reviewed by Codex twice and by the advisor, findings handled; the agent's calls D2, D4 to D13 are listed for the user's confirmation before the push) Size: full Mode: gated Type: bug class and chore (#348, #351 to #358) and feature (#350) Source: GitHub issues #348 and #350 to #358 of this repository as they stood on 2026-11-13 (copies in `sources/`), the conversation of 2026-11-13 ("process all open issues, fix them all in this PR"), and a read of `main` at `a9938193b` by five read-only verifiers. Evidence record: [evidence.md](evidence.md). #350 has its own folder, [../2026-11-13-terminal-screen-350/](../2026-11-13-terminal-screen-350/terminal-screen-design.md), because its design did not pass its first review. @@ -72,6 +72,7 @@ Priority is Must unless marked. "Test seam" names the existing test file that ta WHEN any font finishes loading in the document while a terminal is rendered, the terminal SHALL re-measure and repaint at its current size. - Given a rendered terminal with an unchanged mount size, When `document.fonts` dispatches `loadingdone`, Then the terminal is re-measured and `clearTextureAtlas` and `refresh` each run once. An owner refits. A mirror keeps the server's columns and rows and sends no size claim: the test uses a fit mock that really resizes the terminal, as the real fit addon does, and asserts the mirror's grid is unchanged. The assertions for owner, mirror and the legacy path equal those of the existing device-pixel-ratio test. +- Given a real xterm with an unchanged mount size, When the font's glyph metrics change and `loadingdone` fires, Then the terminal's cell dimensions are measured again before any size is proposed (a repaint alone is not enough: the fit addon reads cached cell dimensions). - Given the terminal is unmounted, When `loadingdone` fires, Then nothing runs. - Test seam: `apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx`, next to "refits when the device pixel ratio changes under an unchanged mount". ### R2. Late creation @@ -103,6 +104,7 @@ Every terminal in the client SHALL count columns by Unicode 11 (the `@xterm/addo WHEN the reader has brought a reasoning row into view away from the turn's end and expands or collapses it, the row's header SHALL stay within 2 px of where it was. This is the invariant the packaged spec `chat-reasoning-rows` asserts in `expandThoughtAwayFromEnd` (scroll the row to the center, toggle, compare the header's position). - Diagnose first, before any fix. Known on 2026-11-13: the packaged harness already runs at a 1000x720 viewport on every platform, and the spec passes there on Linux (WebKitGTK), so neither that viewport nor the shared code alone reproduces it; Windows differs by WebView2 (Chromium) and a device pixel ratio of 2. Step 1: get a live trace of which code path scrolls (the toggle's end reveal, the grown-turn follow, or the list's end maintenance, with the real value of each decision), from Chromium at device pixel ratio 2 if that reproduces it, otherwise from the Windows VM. Step 2: a unit test reproduces that path with the trace's metrics. The fix goes at the first wrong decision the trace names; no fix is chosen before it. - Given the reader scrolled a reasoning row to the center without a wheel or touch gesture (a programmatic scroll), When the row is toggled, Then no scroll to the end is made within three animation frames. +- Given one work row that groups `[thought A, tool B, thought C]`, When thought A is toggled, Then its header stays in place (A is not the newest thought although no later list row exists), and toggling C at the turn's end still reveals the end. - Given the newest reasoning row at the end of an anchored turn, Then the existing reveal still happens (the #173 behavior, asserted by `expandStreamingThoughtAtEnd`). - Proof on Windows: the packaged `chat-reasoning-rows` spec passes in the Windows VM at the viewport where it failed. - Test seam: `apps/web/src/components/ChatView.test.tsx`, "a pointer press on a Thinking row's toggle before a toggle at an anchored turn's end". @@ -130,6 +132,8 @@ Using R4's record, a test replays an attach followed by a font-metrics change an WHEN "Fit to this window" is pressed and no size request can be sent, the size notice SHALL say that the server kept this size, in a status region. No request is sent and focus stays on the button. ### R18. Host dashboard re-render (Should) +Measure first: a React Profiler `onRender` probe on the values view under the compiled build the tests use. IF a clock tick alone costs a render of the values view, the following holds; IF the compiler already skips it, the measurement is recorded and nothing changes. + The values view SHALL not re-render when only the clock tick changed. ### R19. Mixed versions (decide by probe) @@ -216,7 +220,7 @@ A Files source registered during the install SHALL be held until the install end A test SHALL fail when a `desktop:` event name exists on only one side of the bridge. ### R39. Toast reachability (decide by test) -A DOM test opens the installing dialog over the remote-update toast. IF the toast's action is inert, the action SHALL be reachable from the dialog; otherwise the test stays as proof. +A DOM test opens the installing dialog over the remote-update toast. IF the toast's action is inert, device management SHALL be usable while the installing dialog is up (reached from the dialog and shown above it, with Files saving still held); a callback that only navigates the page beneath the dialog does not meet this. Otherwise the test stays as proof. **#358 Web and terminal** diff --git a/docs/plans/2026-11-13-terminal-screen-350/README.md b/docs/plans/2026-11-13-terminal-screen-350/README.md new file mode 100644 index 000000000..89c0a8186 --- /dev/null +++ b/docs/plans/2026-11-13-terminal-screen-350/README.md @@ -0,0 +1,30 @@ +# #350: server-side terminal screen (status on 2026-11-13) + +State: **design in rework, no code written.** This folder is not yet a plan folder (it has no `plan.md`). + +- `terminal-screen-design.md`: first design draft. Status Draft. +- `reviews/design-review-1.md`: Codex (`gpt-6.1-sol`, effort high) review of that draft. Verdict: needs rework, 13 findings, several reproduced against the installed xterm 6. + +## What the review settled + +Accepted findings, in two groups: + +1. **Serializer fidelity (findings 4 to 7).** The restoration order written in prose still breaks on byte sequences the reviewer produced: a saved cursor taken in origin mode, the inactive alternate screen's saved state, saved character sets, the preceding-character state that `REP` and combining marks use, and xterm's behavior at the right margin with auto-wrap off. Two rounds on paper each produced new failing sequences. The restoration order is therefore to be settled by the differential harness, not by prose: the reviewer's sequences become fixtures that fail first, plus a seeded generator, and the harness compares an uninterrupted `@xterm/headless` terminal with checkpoint-plus-continuation. +2. **Protocol (findings 1 to 3, 9, 10).** A harness cannot catch these, so they need a revised design and a second review: the client's refetch must not drop events the live renderer still needs; the startup-query handoff must not reset the terminal again; catch-up after a restart must be fenced by generation; snapshot bytes must be budgeted on every delivery path; memory must be budgeted for combined text and links, not only cells. + +Findings 11, 12 and 13 were about the other issues' spec and are fixed there (`../2026-11-13-issue-batch-348-358/spec.md`: R1, R35, R12). + +## Facts established after the review + +- **Upstream loss needs no invalidation.** When the publisher falls behind the PTY reader, attached windows receive only the published gap event (`publish_output_gap` in `apps/server/src/terminal/manager.rs`); the lost bytes reach nobody. A server screen fed with exactly the published bytes therefore still equals a window that stayed attached, which is the goal. Marking the screen invalid would only make a re-attach worse than an attached window. The first draft's `UpstreamLag` invalidation and the "valid again after RIS" rule are to be dropped; RIS does not reset the title in xterm, as the review showed. To confirm with the reviewer in round 2. +- **avt 0.20.0's API** (from its source, per the review): the type is `avt::Terminal`, not `avt::Vt`; `Line::is_wrapped()` is public and means "continues onto the next row"; the saved cursor and character sets are private, the saved cursor holds no character set, and there are two character-set slots; the scrollback limit is set at construction only; `dump()` omits scrollback and targets a fresh avt terminal. Vendoring is feasible, but the patch is larger than the three changes the draft names. +- The browser xterm's `unicode` getter requires `allowProposedApi`, and restoring focus reporting reaches the focus report synchronously. +- The client's switch to Unicode 11 widths ships with the other issues (R5 there), so it is no longer part of this work. + +## Next steps, in order + +1. Harness first: vendor avt under `third_party/avt`, write `apps/server/src/terminal/screen.rs` and the fixture generator, and add the differential test with the reviewer's failing sequences. No manager, wire or client change. +2. Revise the design's protocol sections (client refetch, startup queries, generation-fenced catch-up, budgets) and get a second Codex review plus the advisor's. +3. Only then write `spec.md` and `plan.md` here and implement the wire and client phases. + +If the harness is still red after two fix rounds, the question goes to the user: keep going, or choose a different base for the emulator. From aef9efd410eb65f12a34fed401d2035d363b207d Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:36:14 -0300 Subject: [PATCH 004/115] feat(browser): let an occlusion lease carry a rectangle (#358) A native surface now hides only when a lease's rectangle overlaps it; a lease without a rectangle still hides every surface. Co-Authored-By: Claude Fable 5.2 --- .../src/browser/browserSurfaceStore.test.ts | 54 ++++++++++++++++++- apps/web/src/browser/browserSurfaceStore.ts | 37 +++++++++++-- .../src/browser/browserSurfaceSync.test.ts | 30 ++++++++++- apps/web/src/browser/browserSurfaceSync.ts | 7 +-- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 73082c71b..92591ddca 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -2,13 +2,16 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { acquireBrowserSurface, + acquireBrowserSurfaceOcclusion, + isBrowserSurfaceOccluded, resolveBrowserSurfacePanelRect, useBrowserSurfaceStore, + type BrowserSurfaceRect, } from "./browserSurfaceStore"; describe("browserSurfaceStore", () => { beforeEach(() => { - useBrowserSurfaceStore.setState({ byTabId: {} }); + useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Map() }); }); it("tracks content dimensions for a browser that has never been visible", () => { @@ -198,4 +201,53 @@ describe("browserSurfaceStore", () => { owner: null, }); }); + + describe("occlusion by rectangle", () => { + const surface = { x: 0, y: 0, width: 400, height: 300 }; + const owners = (...rects: Array) => + new Map(rects.map((rect) => [Symbol("owner"), rect] as const)); + + it("is occluded by a lease without a rectangle", () => { + expect(isBrowserSurfaceOccluded(surface, owners(null))).toBe(true); + }); + + it("is occluded by an overlapping rectangle", () => { + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 390, y: 290, width: 50, height: 50 })), + ).toBe(true); + }); + + it("is not occluded by a disjoint rectangle or one that only touches an edge", () => { + expect(isBrowserSurfaceOccluded(surface, owners())).toBe(false); + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 500, y: 0, width: 100, height: 100 })), + ).toBe(false); + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 400, y: 0, width: 100, height: 100 })), + ).toBe(false); + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 0, y: 300, width: 100, height: 100 })), + ).toBe(false); + }); + + it("replaces a lease's rectangle on update and removes it on release", () => { + const first = { x: 500, y: 0, width: 100, height: 100 }; + const moved = { x: 100, y: 100, width: 50, height: 50 }; + const lease = acquireBrowserSurfaceOcclusion(first); + expect([...useBrowserSurfaceStore.getState().occlusionOwners.values()]).toEqual([first]); + + lease.update(moved); + expect([...useBrowserSurfaceStore.getState().occlusionOwners.values()]).toEqual([moved]); + + lease.release(); + lease.update(first); + expect(useBrowserSurfaceStore.getState().occlusionOwners.size).toBe(0); + }); + + it("treats a lease taken without a rectangle as covering everything", () => { + const lease = acquireBrowserSurfaceOcclusion(); + expect([...useBrowserSurfaceStore.getState().occlusionOwners.values()]).toEqual([null]); + lease.release(); + }); + }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 11287562c..659ad5df9 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -27,7 +27,8 @@ export interface BrowserSurfaceContentPresentation { interface BrowserSurfaceStoreState { readonly byTabId: Record; - readonly occlusionOwners: ReadonlySet; + /** Occlusion leases by owner; a null rectangle covers every surface. */ + readonly occlusionOwners: ReadonlyMap; readonly claim: (tabId: string, owner: symbol) => void; readonly present: ( tabId: string, @@ -45,9 +46,26 @@ export interface BrowserSurfaceLease { } export interface BrowserSurfaceOcclusionLease { + readonly update: (rect: BrowserSurfaceRect | null) => void; readonly release: () => void; } +const rectsOverlap = (left: BrowserSurfaceRect, right: BrowserSurfaceRect): boolean => + left.x < right.x + right.width && + right.x < left.x + left.width && + left.y < right.y + right.height && + right.y < left.y + left.height; + +export function isBrowserSurfaceOccluded( + rect: BrowserSurfaceRect, + owners: ReadonlyMap, +): boolean { + for (const owner of owners.values()) { + if (owner === null || rectsOverlap(rect, owner)) return true; + } + return false; +} + export function resolveBrowserSurfacePanelRect( byTabId: Readonly>, tabId: string, @@ -77,7 +95,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): export const useBrowserSurfaceStore = create()((set) => ({ byTabId: {}, - occlusionOwners: new Set(), + occlusionOwners: new Map(), claim: (tabId, owner) => set((state) => { const current = state.byTabId[tabId]; @@ -157,20 +175,29 @@ export const useBrowserSurfaceStore = create()((set) = }), })); -export function acquireBrowserSurfaceOcclusion(): BrowserSurfaceOcclusionLease { +export function acquireBrowserSurfaceOcclusion( + rect: BrowserSurfaceRect | null = null, +): BrowserSurfaceOcclusionLease { const owner = Symbol("browser-surface-occlusion"); let released = false; useBrowserSurfaceStore.setState((state) => ({ - occlusionOwners: new Set(state.occlusionOwners).add(owner), + occlusionOwners: new Map(state.occlusionOwners).set(owner, rect), })); return { + update: (next) => { + if (released) return; + useBrowserSurfaceStore.setState((state) => { + if (!state.occlusionOwners.has(owner)) return state; + return { occlusionOwners: new Map(state.occlusionOwners).set(owner, next) }; + }); + }, release: () => { if (released) return; released = true; useBrowserSurfaceStore.setState((state) => { if (!state.occlusionOwners.has(owner)) return state; - const occlusionOwners = new Set(state.occlusionOwners); + const occlusionOwners = new Map(state.occlusionOwners); occlusionOwners.delete(owner); return { occlusionOwners }; }); diff --git a/apps/web/src/browser/browserSurfaceSync.test.ts b/apps/web/src/browser/browserSurfaceSync.test.ts index 1460d5902..32b12ae14 100644 --- a/apps/web/src/browser/browserSurfaceSync.test.ts +++ b/apps/web/src/browser/browserSurfaceSync.test.ts @@ -32,7 +32,7 @@ describe("browserSurfaceSync", () => { }; beforeEach(() => { - useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Set() } as never); + useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Map() }); }); afterEach(() => { @@ -155,6 +155,34 @@ describe("browserSurfaceSync", () => { stop(); }); + it("hides a surface only while a rectangle lease overlaps it", async () => { + const setBounds = vi.fn().mockResolvedValue(undefined); + const stop = startSync(setBounds); + const surface = acquireBrowserSurface("sync-rect-occlusion"); + const rect = { x: 0, y: 0, width: 400, height: 300 }; + surface.present(rect, true); + + const popup = acquireBrowserSurfaceOcclusion({ x: 500, y: 0, width: 100, height: 100 }); + await flushPromises(); + expect(setBounds.mock.calls).toEqual([["sync-rect-occlusion", rect, true]]); + + popup.update({ x: 100, y: 100, width: 50, height: 50 }); + await flushPromises(); + expect(setBounds.mock.calls).toEqual([ + ["sync-rect-occlusion", rect, true], + ["sync-rect-occlusion", rect, false], + ]); + + popup.release(); + await flushPromises(); + expect(setBounds.mock.calls).toEqual([ + ["sync-rect-occlusion", rect, true], + ["sync-rect-occlusion", rect, false], + ["sync-rect-occlusion", rect, true], + ]); + stop(); + }); + it("restores the latest desired bounds after an occlusion", async () => { const setBounds = vi.fn().mockResolvedValue(undefined); const stop = startSync(setBounds); diff --git a/apps/web/src/browser/browserSurfaceSync.ts b/apps/web/src/browser/browserSurfaceSync.ts index 8ccc805a4..14637b129 100644 --- a/apps/web/src/browser/browserSurfaceSync.ts +++ b/apps/web/src/browser/browserSurfaceSync.ts @@ -1,6 +1,6 @@ import type { DesktopPreviewBridge } from "@bibcode/contracts"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { isBrowserSurfaceOccluded, useBrowserSurfaceStore } from "./browserSurfaceStore"; interface SyncedPresentation { readonly x: number; @@ -83,7 +83,6 @@ export function startBrowserSurfaceSync( }; const push = (state: ReturnType) => { - const occluded = state.occlusionOwners.size > 0; for (const [tabId, presentation] of Object.entries(state.byTabId)) { if (!presentation.rect) continue; const next: SyncedPresentation = { @@ -91,7 +90,9 @@ export function startBrowserSurfaceSync( y: presentation.rect.y, width: presentation.rect.width, height: presentation.rect.height, - visible: presentation.visible && !occluded, + visible: + presentation.visible && + !isBrowserSurfaceOccluded(presentation.rect, state.occlusionOwners), }; schedule(tabId, next); } From 3170c8872000c71c55edc05fcbd19ef445129dc8 Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:39:53 -0300 Subject: [PATCH 005/115] fix(files): hold a Files source registered during a remote update install (#357) Co-Authored-By: Claude Opus 5.5 --- .../components/files/unsavedFileEdits.test.ts | 27 +++++++++++++++++++ .../src/components/files/unsavedFileEdits.ts | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/unsavedFileEdits.test.ts b/apps/web/src/components/files/unsavedFileEdits.test.ts index d2ab28f3a..3c3284c19 100644 --- a/apps/web/src/components/files/unsavedFileEdits.test.ts +++ b/apps/web/src/components/files/unsavedFileEdits.test.ts @@ -138,12 +138,24 @@ describe("unsaved file edits across the window", () => { }); describe("desktop close requests", () => { + let endInstall: (() => void) | null = null; + afterEach(() => { + // Module state outlives a test: end any install a test left blocked. + endInstall?.(); + endInstall = null; + window.sessionStorage.clear(); + }); + function fakeBridge() { let listener: ((request: DesktopCloseRequest) => void) | null = null; const replies: [number, string][] = []; return { replies, send: (request: DesktopCloseRequest) => listener?.(request), + onUpdateInstallEnded: (next: () => void) => { + endInstall = next; + return () => {}; + }, onCloseRequest: (next: (request: DesktopCloseRequest) => void) => { listener = next; return () => { @@ -200,6 +212,21 @@ describe("unsaved file edits across the window", () => { await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([6, "stay"])); }); + it("a source registered during an update install is held until the install ends", async () => { + register(fakeSource()); + const bridge = fakeBridge(); + cleanups.push(respondToDesktopCloseRequests(bridge)); + bridge.send({ requestId: 11, reason: "update" }); + await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([11, "allow"])); + + const late = register(fakeSource()); + expect(late.holdSavingForLeave).toHaveBeenCalledOnce(); + expect(late.release).not.toHaveBeenCalled(); + endInstall!(); + endInstall = null; + expect(late.release).toHaveBeenCalledOnce(); + }); + it("Save all answers allow once every file is saved", async () => { const source = register(fakeSource(["notes.md"])); const bridge = fakeBridge(); diff --git a/apps/web/src/components/files/unsavedFileEdits.ts b/apps/web/src/components/files/unsavedFileEdits.ts index 2ebec07bd..a2ff581c6 100644 --- a/apps/web/src/components/files/unsavedFileEdits.ts +++ b/apps/web/src/components/files/unsavedFileEdits.ts @@ -32,10 +32,12 @@ let discardHolds: (() => void)[] = []; * without restarting. Saving is held and the installing dialog keeps edits out, so no edit made * during the install is lost when it restarts the app. */ -let updateInstall: { readonly release: () => void } | null = null; +let updateInstall: { readonly holds: (() => void)[]; readonly release: () => void } | null = null; export function registerUnsavedSource(source: UnsavedSource): () => void { sources.add(source); + // A registry that mounts during the install is held like the ones that were there before it. + updateInstall?.holds.push(source.holdSavingForLeave()); return () => { sources.delete(source); }; @@ -84,6 +86,7 @@ function blockForUpdateInstall(): void { if (updateInstall !== null) return; const holds = [...sources].map((source) => source.holdSavingForLeave()); updateInstall = { + holds, release: () => { for (const release of holds) release(); }, From adfc606485615958a243cddfd77b2d58a2b6ba55 Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:39:55 -0300 Subject: [PATCH 006/115] fix(ui): hide a macOS native preview only under an open popup that overlaps it (#358) Popover, Menu, Select, Combobox and Autocomplete lease their positioner's rectangle while it carries data-open and follow it frame by frame; a kept-mounted closed popup holds no lease. Tooltips take none; dialogs keep their full lease. Co-Authored-By: Claude Fable 5.2 --- apps/web/src/components/ui/autocomplete.tsx | 5 + apps/web/src/components/ui/combobox.tsx | 4 + apps/web/src/components/ui/menu.tsx | 5 + apps/web/src/components/ui/popover.tsx | 5 + .../ui/popup-occlusion.dom.test.tsx | 242 ++++++++++++++++++ apps/web/src/components/ui/select.tsx | 5 + .../ui/useNativePreviewOcclusion.ts | 4 +- .../src/components/ui/usePopupOcclusion.ts | 76 ++++++ 8 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ui/popup-occlusion.dom.test.tsx create mode 100644 apps/web/src/components/ui/usePopupOcclusion.ts diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 10e41af07..bf6ae2dc8 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -1,9 +1,11 @@ "use client"; import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"; +import { useRef } from "react"; import { ChevronsUpDownIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; +import { PopupOcclusion } from "./usePopupOcclusion"; import { Input } from "~/components/ui/input"; import { ScrollArea } from "~/components/ui/scroll-area"; @@ -91,6 +93,7 @@ function AutocompletePopup({ side?: AutocompletePrimitive.Positioner.Props["side"]; anchor?: AutocompletePrimitive.Positioner.Props["anchor"]; }) { + const positionerRef = useRef(null); return ( + (null); return ( + (null); return ( + (null); return ( + ({ desktop: true })); +vi.mock("~/env", () => ({ + get isDesktopHost() { + return host.desktop; + }, +})); + +import { + acquireBrowserSurface, + isBrowserSurfaceOccluded, + useBrowserSurfaceStore, +} from "~/browser/browserSurfaceStore"; +import { + Autocomplete, + AutocompleteInput, + AutocompleteList, + AutocompletePopup, +} from "./autocomplete"; +import { Combobox, ComboboxInput, ComboboxList, ComboboxPopup } from "./combobox"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "./popover"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip"; + +let container: HTMLDivElement; +let root: Root; +let originalGetAnimations: PropertyDescriptor | undefined; +let platform: PropertyDescriptor | undefined; +let frames: FrameRequestCallback[] = []; + +const owners = () => useBrowserSurfaceStore.getState().occlusionOwners; + +function flushFrames() { + const pending = frames; + frames = []; + act(() => { + for (const frame of pending) frame(0); + }); +} + +function PopoverCase({ open }: { open: boolean }) { + return ( + + Open + Inside + + ); +} + +const cases: Array<[string, (props: { open: boolean }) => ReactNode]> = [ + ["Popover", PopoverCase], + [ + "Menu", + ({ open }) => ( + + Open + + Item + + + ), + ], + [ + "Select", + ({ open }) => ( + + ), + ], + [ + "Combobox", + ({ open }) => ( + + + + {(item: string) => {item}} + + + ), + ], + [ + "Autocomplete", + ({ open }) => ( + + + + {(item: string) => {item}} + + + ), + ], +]; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + originalGetAnimations = Object.getOwnPropertyDescriptor(Element.prototype, "getAnimations"); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + platform = Object.getOwnPropertyDescriptor(navigator, "platform"); + Object.defineProperty(navigator, "platform", { configurable: true, value: "MacIntel" }); + host.desktop = true; + frames = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + // A cancelled frame must not run; ids are positions in the current queue. + if (frames[id - 1]) frames[id - 1] = () => undefined; + }); + useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Map() }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + document.body.replaceChildren(); + vi.unstubAllGlobals(); + if (platform) Object.defineProperty(navigator, "platform", platform); + else Reflect.deleteProperty(navigator, "platform"); + if (originalGetAnimations) { + Object.defineProperty(Element.prototype, "getAnimations", originalGetAnimations); + } else { + Reflect.deleteProperty(Element.prototype, "getAnimations"); + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe.each(cases)("%s on the macOS desktop app", (_name, Case) => { + it("leases its rectangle while open and releases it when it closes", async () => { + await act(async () => root.render()); + expect(owners().size).toBe(0); + + await act(async () => root.render()); + expect(owners().size).toBe(1); + expect([...owners().values()][0]).not.toBeNull(); + + await act(async () => root.render()); + expect(owners().size).toBe(0); + }); + + it("releases the lease when it unmounts while open", async () => { + await act(async () => root.render()); + expect(owners().size).toBe(1); + await act(async () => root.render(null)); + expect(owners().size).toBe(0); + }); + + it("takes no lease in the browser or off macOS", async () => { + host.desktop = false; + await act(async () => root.render()); + expect(owners().size).toBe(0); + await act(async () => root.render(null)); + + host.desktop = true; + Object.defineProperty(navigator, "platform", { configurable: true, value: "Win32" }); + await act(async () => root.render()); + expect(owners().size).toBe(0); + }); +}); + +describe("a kept-mounted popover", () => { + it("marks its positioner open only while open, and holds no lease when it never opened", async () => { + await act(async () => root.render()); + const positioner = document.querySelector("[data-slot=popover-positioner]"); + expect(positioner).not.toBeNull(); + expect(positioner?.hasAttribute("data-open")).toBe(false); + expect(owners().size).toBe(0); + + await act(async () => root.render()); + expect(positioner?.hasAttribute("data-open")).toBe(true); + + await act(async () => root.render()); + expect(document.querySelector("[data-slot=popover-positioner]")).toBe(positioner); + expect(positioner?.hasAttribute("data-open")).toBe(false); + expect(owners().size).toBe(0); + }); + + it("follows the popup when it moves without resizing", async () => { + const surfaceRect = { x: 0, y: 0, width: 400, height: 300 }; + const surface = acquireBrowserSurface("tab"); + surface.present(surfaceRect, true); + const box = { x: 500, y: 0, width: 120, height: 80 }; + + await act(async () => root.render()); + const positioner = document.querySelector("[data-slot=popover-positioner]"); + if (!positioner) throw new Error("Missing positioner"); + positioner.getBoundingClientRect = () => DOMRect.fromRect(box); + + await act(async () => root.render()); + flushFrames(); + expect([...owners().values()]).toEqual([box]); + expect(isBrowserSurfaceOccluded(surfaceRect, owners())).toBe(false); + + box.x = 100; + box.y = 100; + flushFrames(); + expect([...owners().values()]).toEqual([{ x: 100, y: 100, width: 120, height: 80 }]); + expect(isBrowserSurfaceOccluded(surfaceRect, owners())).toBe(true); + + box.x = 450; + flushFrames(); + expect([...owners().values()]).toEqual([{ x: 450, y: 100, width: 120, height: 80 }]); + expect(isBrowserSurfaceOccluded(surfaceRect, owners())).toBe(false); + + await act(async () => root.render()); + expect(owners().size).toBe(0); + surface.release(); + }); +}); + +describe("a tooltip", () => { + it("holds no lease while open", async () => { + await act(async () => + root.render( + + Hint + Text + , + ), + ); + expect(document.body.textContent).toContain("Text"); + expect(owners().size).toBe(0); + }); +}); diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 5d63e430e..d65edf57d 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -2,12 +2,14 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; +import { useRef } from "react"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; +import { PopupOcclusion } from "./usePopupOcclusion"; const Select = SelectPrimitive.Root; @@ -123,6 +125,7 @@ function SelectPopup({ matchTriggerWidth?: boolean; anchor?: SelectPrimitive.Positioner.Props["anchor"]; }) { + const positionerRef = useRef(null); return ( + { diff --git a/apps/web/src/components/ui/usePopupOcclusion.ts b/apps/web/src/components/ui/usePopupOcclusion.ts new file mode 100644 index 000000000..f640c8416 --- /dev/null +++ b/apps/web/src/components/ui/usePopupOcclusion.ts @@ -0,0 +1,76 @@ +import { useEffect, useState, type RefObject } from "react"; +import { + acquireBrowserSurfaceOcclusion, + type BrowserSurfaceRect, +} from "~/browser/browserSurfaceStore"; +import { isDesktopHost } from "~/env"; +import { isMacPlatform } from "~/lib/utils"; + +function measure(element: HTMLElement): BrowserSurfaceRect { + const rect = element.getBoundingClientRect(); + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }; +} + +/** + * A macOS native preview is a view above the webview, so a popup drawn over it would sit under + * it. While `open`, lease the positioner's rectangle, which hides the preview only where the + * popup overlaps it. A frame loop keeps the rectangle current: it also catches movement without + * resizing (anchor scroll, repositioning, the positioner's own transition) that no observer + * reports, and it runs only while open. + */ +export function usePopupOcclusion( + open: boolean, + positionerRef: RefObject, +): void { + useEffect(() => { + const element = positionerRef.current; + if (!open || !element || !isDesktopHost || !isMacPlatform(navigator.platform)) return; + let current = measure(element); + const lease = acquireBrowserSurfaceOcclusion(current); + let frame = requestAnimationFrame(function track() { + const next = measure(element); + if ( + next.x !== current.x || + next.y !== current.y || + next.width !== current.width || + next.height !== current.height + ) { + current = next; + lease.update(next); + } + frame = requestAnimationFrame(track); + }); + return () => { + cancelAnimationFrame(frame); + lease.release(); + }; + }, [open, positionerRef]); +} + +/** + * Render inside a popup's Positioner. Base UI 1.9 marks the Positioner with `data-open` while + * the popup is open, also when its portal stays mounted while closed. + */ +export function PopupOcclusion({ + positionerRef, +}: { + positionerRef: RefObject; +}): null { + const [open, setOpen] = useState(false); + useEffect(() => { + const element = positionerRef.current; + if (!element) return; + const read = () => setOpen(element.hasAttribute("data-open")); + read(); + const observer = new MutationObserver(read); + observer.observe(element, { attributeFilter: ["data-open"] }); + return () => observer.disconnect(); + }, [positionerRef]); + usePopupOcclusion(open, positionerRef); + return null; +} From 4ff7c129f2fd40b4532d533b3cc6732c4571485b Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:41:32 -0300 Subject: [PATCH 007/115] fix(web): re-measure and repaint the terminal when a font finishes loading (#348) A late font changes xterm's cell without changing the mount box, so the ResizeObserver stays silent and the fit addon proposes from the cached cell size. The mount observer now listens for document.fonts loadingdone, re-measures the cell and runs the usual layout and repaint. Co-Authored-By: Claude Opus 5.5 --- .../ThreadTerminalPanel.interactions.test.tsx | 156 ++++++++++++++++++ .../src/components/ThreadTerminalPanel.tsx | 10 ++ apps/web/src/components/terminalSizing.ts | 22 +++ .../components/terminalSizing.xterm.test.ts | 85 ++++++++++ 4 files changed, 273 insertions(+) create mode 100644 apps/web/src/components/terminalSizing.xterm.test.ts diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index 412df1485..f0d8d41fe 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -45,6 +45,7 @@ interface FakeTerminalInstance { readonly resize: ReturnType; readonly scrollToBottom: ReturnType; readonly clearTextureAtlas: ReturnType; + readonly _core: { readonly _charSizeService: { readonly measure: ReturnType } }; readonly dispose: ReturnType; readonly loadAddon: ReturnType; readonly clearSelection: ReturnType; @@ -371,6 +372,8 @@ vi.mock("@xterm/xterm", () => ({ }; readonly scrollToBottom = vi.fn(); readonly clearTextureAtlas = vi.fn(); + // xterm 6.0.0's private cell measurement, which a late font load re-runs (#348). + readonly _core = { _charSizeService: { measure: vi.fn() } }; readonly loadedAddons: Array<{ dispose?: () => void }> = []; readonly dispose = vi.fn(() => { this.disposed = true; @@ -1026,6 +1029,18 @@ function trackedTranscriptRuntime(initialSnapshot: string): { }; } +/** A `document.fonts` whose `loadingdone` the test fires: happy-dom loads no fonts. */ +function stubDocumentFonts(): EventTarget { + const fonts = Object.assign(new EventTarget(), { status: "loaded" }); + Object.defineProperty(document, "fonts", { configurable: true, value: fonts }); + return fonts; +} + +/** The order of the first call to `mocks` after they were last cleared, or Infinity. */ +function firstCallOrder(...mocks: ReturnType[]): number { + return Math.min(...mocks.map((mock) => mock.mock.invocationCallOrder[0] ?? Infinity)); +} + async function flushAnimationFrames(): Promise { await act(async () => { while (animationFrames.size > 0) { @@ -1514,6 +1529,7 @@ afterEach(async () => { xtermState.fitAddons = []; document.body.replaceChildren(); Reflect.deleteProperty(document, "execCommand"); + Reflect.deleteProperty(document, "fonts"); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -3695,6 +3711,146 @@ describe("TerminalViewport mounted lifecycle", () => { }, ); + it.each(["legacy", "owner", "mirror"] as const)( + "re-measures and repaints when a font finishes loading under an unchanged mount (%s)", + { timeout: 30_000 }, + async (mode) => { + // #348: a font that loads after the terminal measured its cell leaves xterm's cached + // cell size stale while the mount keeps its box and the ResizeObserver stays silent. + const fonts = stubDocumentFonts(); + if (mode !== "legacy") { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + } + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport({ visible: true }); + const terminal = view.fakeTerminal!; + const fitAddon = xtermState.fitAddons.at(-1)!; + const fit = fitAddon.fit; + // The fake grid changes only when fit runs, as xterm's does. + fit.mockImplementation(() => { + if (xtermState.proposedDimensions) { + terminal.cols = xtermState.proposedDimensions.cols; + terminal.rows = xtermState.proposedDimensions.rows; + } + }); + await view.flushFrame(); + await view.flushMountFit(); + let claim: string | undefined; + if (mode !== "legacy") { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 80, rows: 24, sizeClaim: mode === "owner" ? null : "other-window" }, + }, + }), + ); + if (mode === "owner") { + claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim! }, + }), + ); + } + } + expect(terminal.rows).toBe(24); + const measure = terminal._core._charSizeService.measure; + xtermState.proposedDimensions = { cols: 80, rows: 23 }; + view.resizeSpy.mockClear(); + fit.mockClear(); + fitAddon.proposeDimensions.mockClear(); + measure.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await act(async () => fonts.dispatchEvent(new Event("loadingdone"))); + await view.flushFrame(); + if (mode === "legacy") { + expect(fit).toHaveBeenCalledOnce(); + expect(terminal.rows).toBe(23); + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 23, + structuredResult: true, + }, + }); + } else if (mode === "owner") { + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 23, + sizeClaim: claim, + structuredResult: true, + }, + }); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 23, sizeClaim: claim! }, + }), + ); + expect(terminal.rows).toBe(23); + } else { + // A mirror keeps the owner's grid and never claims on a font change. + expect(fit).not.toHaveBeenCalled(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(terminal.cols).toBe(80); + expect(terminal.rows).toBe(24); + } + await act(async () => vi.advanceTimersByTime(50)); + expect(terminal.clearTextureAtlas).toHaveBeenCalledOnce(); + expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); + } finally { + vi.useRealTimers(); + } + // The cell is measured before any size is proposed; legacy's fit proposes inside. + expect(measure).toHaveBeenCalledOnce(); + expect(measure.mock.invocationCallOrder[0]).toBeLessThan( + firstCallOrder(fitAddon.proposeDimensions, fit), + ); + }, + ); + + it("does nothing when a font finishes loading after unmount", { timeout: 30_000 }, async () => { + const fonts = stubDocumentFonts(); + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + const terminal = view.fakeTerminal!; + const fit = xtermState.fitAddons.at(-1)!.fit; + await unmount(view.mounted); + view.resizeSpy.mockClear(); + fit.mockClear(); + terminal._core._charSizeService.measure.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + await act(async () => fonts.dispatchEvent(new Event("loadingdone"))); + await view.flushFrame(); + + expect(terminal._core._charSizeService.measure).not.toHaveBeenCalled(); + expect(fit).not.toHaveBeenCalled(); + expect(terminal.clearTextureAtlas).not.toHaveBeenCalled(); + expect(terminal.refresh).not.toHaveBeenCalled(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + }); + it("sends a height-only change of the mount to the PTY as the size owner", async () => { // #348: an Activity card appearing below an AI terminal shrinks only its height. testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx index a190400dd..60eb139d3 100644 --- a/apps/web/src/components/ThreadTerminalPanel.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -100,6 +100,7 @@ import { isTerminalSizingMinimum, proposeTerminalDimensions, registerTerminalSizeReader, + remeasureTerminalCharSize, terminalHostHasArea, } from "./terminalSizing"; import { @@ -2864,8 +2865,17 @@ export function TerminalViewport({ scheduleResize(); } watchPixelRatio(); + // A font that loads later changes the cell without changing the box (#348). + const fonts = typeof document.fonts?.addEventListener === "function" ? document.fonts : null; + const handleFontLoadingDone = () => { + const terminal = terminalRef.current; + if (terminal) remeasureTerminalCharSize(terminal); + scheduleResize(); + }; + fonts?.addEventListener("loadingdone", handleFontLoadingDone); return () => { + fonts?.removeEventListener("loadingdone", handleFontLoadingDone); pixelRatioQuery?.removeEventListener("change", handlePixelRatioChange); observer.disconnect(); workspace?.removeEventListener(CENTER_PANEL_RESIZE_END_EVENT, scheduleResize); diff --git a/apps/web/src/components/terminalSizing.ts b/apps/web/src/components/terminalSizing.ts index 431a8d05a..1e2ce2af8 100644 --- a/apps/web/src/components/terminalSizing.ts +++ b/apps/web/src/components/terminalSizing.ts @@ -1,4 +1,5 @@ import type { FitAddon } from "@xterm/addon-fit"; +import type { Terminal } from "@xterm/xterm"; import { TERMINAL_COLS_MIN, TERMINAL_COLS_MAX, @@ -37,6 +38,27 @@ export function proposeTerminalDimensions( } } +/** + * Measures the terminal's cell again, so the fit addon proposes from a late font's glyphs: + * xterm caches the cell size and a repaint measures nothing (#348). False when the + * installed xterm has no measurement to call. + */ +export function remeasureTerminalCharSize(terminal: Terminal): boolean { + // shortcut: calls xterm 6.0.0's private CharSizeService.measure, feature-detected; remove when xterm exposes a public re-measure + try { + const service = ( + terminal as unknown as { + readonly _core?: { readonly _charSizeService?: { readonly measure?: () => void } }; + } + )._core?._charSizeService; + if (typeof service?.measure !== "function") return false; + service.measure(); + return true; + } catch { + return false; + } +} + /** The fit addon's own floor (`MINIMUM_COLS`, `MINIMUM_ROWS` in @xterm/addon-fit 0.11). */ const FIT_MINIMUM_COLS = 2; const FIT_MINIMUM_ROWS = 1; diff --git a/apps/web/src/components/terminalSizing.xterm.test.ts b/apps/web/src/components/terminalSizing.xterm.test.ts new file mode 100644 index 000000000..51d6d98df --- /dev/null +++ b/apps/web/src/components/terminalSizing.xterm.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import { afterEach, beforeEach, expect, it } from "vite-plus/test"; +import { remeasureTerminalCharSize } from "./terminalSizing"; + +// happy-dom has no layout and no `OffscreenCanvas`, so xterm measures its cell with the DOM +// strategy: the width of 32 "W"s in `.xterm-char-measure-element`, and its height (#348). +let cellWidth = 8; +let cellHeight = 16; +const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth")!; +const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight")!; +let host: HTMLDivElement; +let terminal: Terminal; +let fit: FitAddon; + +beforeEach(() => { + cellWidth = 8; + cellHeight = 16; + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("xterm-char-measure-element") + ? cellWidth * 32 + : offsetWidth.get!.call(this); + }, + }); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + return this.classList.contains("xterm-char-measure-element") + ? cellHeight + : offsetHeight.get!.call(this); + }, + }); + host = document.createElement("div"); + host.style.width = "640px"; + host.style.height = "320px"; + document.body.append(host); + terminal = new Terminal({ cols: 20, rows: 5 }); + fit = new FitAddon(); + terminal.loadAddon(fit); + terminal.open(host); + // happy-dom computes no padding from xterm's stylesheet; the fit addon parses it. + terminal.element!.style.padding = "0px"; + expect(host.querySelector(".xterm-char-measure-element")).not.toBeNull(); + expect(fit.proposeDimensions()).toBeDefined(); + expect(Number.isFinite(fit.proposeDimensions()!.cols)).toBe(true); +}); + +afterEach(() => { + terminal.dispose(); + host.remove(); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetWidth); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetHeight); +}); + +it( + "re-measures a changed cell size under an unchanged mount before proposing dimensions", + { timeout: 30_000 }, + () => { + const before = fit.proposeDimensions()!; + // The font's new glyph metrics, which xterm's cached cell size does not see. + cellWidth = 10; + expect(fit.proposeDimensions()).toEqual(before); + + expect(remeasureTerminalCharSize(terminal)).toBe(true); + + const after = fit.proposeDimensions()!; + expect(after.cols).toBeLessThan(before.cols); + expect(after.rows).toBe(before.rows); + }, +); + +// `remeasureTerminalCharSize` calls a private xterm method; an upgrade that drops it must fail +// here, not silently leave a late font with stale cell metrics. +it( + "the installed xterm still has the private char-size measure the font repaint calls", + { timeout: 30_000 }, + () => { + const core = (terminal as unknown as { _core?: { _charSizeService?: { measure?: unknown } } }) + ._core; + expect(typeof core?._charSizeService?.measure).toBe("function"); + }, +); From a74aacecb974c807f4c12b113073783f452985ed Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:44:19 -0300 Subject: [PATCH 008/115] fix(files): restore the update install block after a reload and tell a failed state query from an answer (#357) A sessionStorage marker restores the block when the window loads; only a confirmed state with no remote requester clears it. The bridge's getUpdateState now rejects on a native failure and falls back only when the desktop lacks the command. Co-Authored-By: Claude Opus 5.5 --- .../files/UnsavedChangesCloseDialog.test.tsx | 265 ++++++++++++++++++ .../src/components/files/unsavedFileEdits.ts | 134 ++++++++- apps/web/src/tauriDesktopBridge.test.ts | 83 ++++++ apps/web/src/tauriDesktopBridge.ts | 25 +- 4 files changed, 502 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx b/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx index 1830fbbac..17bd71d44 100644 --- a/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx +++ b/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx @@ -449,6 +449,271 @@ describe("UnsavedChangesCloseHost", () => { }); }); + describe("the update install state when the window loads", () => { + const MARKER = "bibcode:update-install-allowed"; + type State = { + readonly requestedBy: { readonly label: string; readonly detail: string | null } | null; + readonly phase: string; + }; + const notRunning: State = { requestedBy: null, phase: "idle" }; + const running: State = { + requestedBy: { label: "Test User", detail: null }, + phase: "installing", + }; + + function stateBridge(getUpdateState?: () => Promise) { + const bridge = { + replies: [] as [number, string][], + send: null as ((request: DesktopCloseRequest) => void) | null, + endInstall: null as (() => void) | null, + pushState: null as ((state: State) => void) | null, + onCloseRequest: (listener: (request: DesktopCloseRequest) => void) => { + bridge.send = listener; + return () => {}; + }, + replyCloseRequest: async (requestId: number, reply: string) => { + bridge.replies.push([requestId, reply]); + }, + onUpdateInstallEnded: (listener: () => void) => { + bridge.endInstall = listener; + return () => {}; + }, + onUpdateState: (listener: (state: State) => void) => { + bridge.pushState = listener; + return () => { + bridge.pushState = null; + }; + }, + getUpdateState: getUpdateState ? vi.fn(getUpdateState) : undefined, + }; + if (!getUpdateState) delete bridge.getUpdateState; + return bridge; + } + + /** Loads the window again: fresh modules, the same sessionStorage, as a reload does. */ + async function loadWindow(bridge: ReturnType) { + await act(async () => root.unmount()); + vi.resetModules(); + const host = await import("./UnsavedChangesCloseDialog"); + const { FileEditingSessionRegistry: Registry } = await import("./fileEditingSessionRegistry"); + const { FileEditingSession: EditingSession } = await import("./fileEditingSession"); + window.desktopBridge = bridge as unknown as DesktopBridge; + root = createRoot(container); + await act(async () => root.render()); + return { + openFile(relativePath: string, persist: Persist = vi.fn(async () => saved())) { + const registry = new Registry(); + registries.push(registry as unknown as FileEditingSessionRegistry); + return registry.getOrCreate( + relativePath, + () => + new EditingSession({ + cwd: "/repo", + relativePath, + debounceMs: 500, + base: { version: "v1", contents: "old\n" }, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + onConflictChange: vi.fn(), + }), + ); + }, + }; + } + + const advance = (ms: number) => + act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + + beforeEach(() => { + vi.useFakeTimers(); + window.sessionStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + window.sessionStorage.clear(); + delete window.desktopBridge; + }); + + it("reload during an install: the marker restores the block while the first query rejects", async () => { + // The window allows the install through the close protocol, which writes the marker. + const before = stateBridge(() => new Promise(() => {})); + await loadWindow(before); + await act(async () => before.send!({ requestId: 3, reason: "update" })); + await advance(0); + await vi.waitFor(() => expect(before.replies.at(-1)).toEqual([3, "allow"]), { + timeout: 30_000, + }); + expect(window.sessionStorage.getItem(MARKER)).toBe("1"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const after = stateBridge(() => Promise.reject(new Error("state unavailable"))); + const reloaded = await loadWindow(after); + expect(dialogText()).toContain("Installing an update"); + const persist = vi.fn(async () => saved()); + reloaded.openFile("notes.md", persist).changeFromRichEditor("edit after the reload"); + await advance(700); + expect(persist).not.toHaveBeenCalled(); + expect(after.getUpdateState).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledOnce(); + await advance(2_000); + expect(after.getUpdateState).toHaveBeenCalledTimes(2); + expect(dialogText()).toContain("Installing an update"); + expect(window.sessionStorage.getItem(MARKER)).toBe("1"); + }); + + it("reload during an install: a confirmed not-running answer clears the block and the marker", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + window.sessionStorage.setItem(MARKER, "1"); + const answers: (() => Promise)[] = [ + () => Promise.reject(new Error("state unavailable")), + async () => notRunning, + ]; + const bridge = stateBridge(() => answers.shift()!()); + const loaded = await loadWindow(bridge); + const persist = vi.fn(async () => saved()); + const session = loaded.openFile("notes.md", persist); + session.changeFromRichEditor("held edit"); + await advance(700); + expect(persist).not.toHaveBeenCalled(); + expect(dialogText()).toContain("Installing an update"); + + await advance(2_000); + expect(bridge.getUpdateState).toHaveBeenCalledTimes(2); + expect(dialogText()).toBeUndefined(); + expect(window.sessionStorage.getItem(MARKER)).toBeNull(); + // Saving resumes: the held edit is written. + await advance(700); + expect(persist).toHaveBeenCalledWith("notes.md", "held edit", expect.anything()); + expect(session.unsavedContents).toBeNull(); + }); + + it("reload during an install: an answer that still names the requester keeps the block", async () => { + // Between the allow and the backup the phase still reads "available". + window.sessionStorage.setItem(MARKER, "1"); + const bridge = stateBridge(async () => ({ ...running, phase: "available" })); + await loadWindow(bridge); + await advance(0); + expect(bridge.getUpdateState).toHaveBeenCalledOnce(); + expect(dialogText()).toContain("Installing an update"); + expect(window.sessionStorage.getItem(MARKER)).toBe("1"); + }); + + it("a fresh window with no marker holds nothing until an answer says an install runs", async () => { + let answer!: (state: State) => void; + const bridge = stateBridge( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + const loaded = await loadWindow(bridge); + expect(dialogText()).toBeUndefined(); + const persist = vi.fn(async () => saved()); + const session = loaded.openFile("notes.md", persist); + session.changeFromRichEditor("free edit"); + await act(async () => { + await expect(session.flush()).resolves.toBe("saved"); + }); + expect(persist).toHaveBeenCalledOnce(); + + await act(async () => answer(running)); + expect(dialogText()).toContain("Installing an update"); + expect(window.sessionStorage.getItem(MARKER)).toBe("1"); + }); + + it("a failed query never releases a known block", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.sessionStorage.setItem(MARKER, "1"); + const answers: (() => Promise)[] = [ + () => new Promise(() => {}), + () => Promise.reject(new Error("state unavailable")), + () => new Promise(() => {}), + ]; + const bridge = stateBridge(() => answers.shift()!()); + await loadWindow(bridge); + // The first query never answers: after 5 seconds it counts as failed and is asked again. + await advance(5_000); + expect(dialogText()).toContain("Installing an update"); + await advance(2_000); + expect(bridge.getUpdateState).toHaveBeenCalledTimes(2); + await advance(2_000); + expect(bridge.getUpdateState).toHaveBeenCalledTimes(3); + expect(dialogText()).toContain("Installing an update"); + expect(window.sessionStorage.getItem(MARKER)).toBe("1"); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("a failed query never releases a known block, even when its late answer comes", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + window.sessionStorage.setItem(MARKER, "1"); + let late!: (state: State) => void; + const answers: (() => Promise)[] = [ + () => + new Promise((resolve) => { + late = resolve; + }), + () => new Promise(() => {}), + ]; + const bridge = stateBridge(() => answers.shift()!()); + await loadWindow(bridge); + await advance(5_000); + await act(async () => late(notRunning)); + expect(dialogText()).toContain("Installing an update"); + expect(window.sessionStorage.getItem(MARKER)).toBe("1"); + }); + + it("a pushed update state ends the wait", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + window.sessionStorage.setItem(MARKER, "1"); + const bridge = stateBridge(() => Promise.reject(new Error("state unavailable"))); + await loadWindow(bridge); + await advance(0); + expect(bridge.getUpdateState).toHaveBeenCalledOnce(); + await act(async () => bridge.pushState!(running)); + await advance(10_000); + expect(bridge.getUpdateState).toHaveBeenCalledOnce(); + expect(dialogText()).toContain("Installing an update"); + }); + + it("a desktop without getUpdateState clears a stale marker and holds nothing", async () => { + window.sessionStorage.setItem(MARKER, "1"); + const loaded = await loadWindow(stateBridge()); + expect(dialogText()).toBeUndefined(); + expect(window.sessionStorage.getItem(MARKER)).toBeNull(); + const persist = vi.fn(async () => saved()); + const session = loaded.openFile("notes.md", persist); + session.changeFromRichEditor("free edit"); + await act(async () => { + await expect(session.flush()).resolves.toBe("saved"); + }); + }); + + it("an unusable sessionStorage means no marker and no crash", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + const bridge = stateBridge(async () => notRunning); + await loadWindow(bridge); + await advance(0); + expect(dialogText()).toBeUndefined(); + // An allowed install still blocks, without a marker to restore it. + await act(async () => bridge.send!({ requestId: 4, reason: "update" })); + await advance(0); + await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([4, "allow"]), { + timeout: 30_000, + }); + expect(dialogText()).toContain("Installing an update"); + }); + }); + it("answers a desktop close request through the bridge", async () => { await act(async () => root.unmount()); const replies: [number, string][] = []; diff --git a/apps/web/src/components/files/unsavedFileEdits.ts b/apps/web/src/components/files/unsavedFileEdits.ts index a2ff581c6..85ce087ae 100644 --- a/apps/web/src/components/files/unsavedFileEdits.ts +++ b/apps/web/src/components/files/unsavedFileEdits.ts @@ -1,4 +1,9 @@ -import type { DesktopBridge, DesktopCloseReply, DesktopCloseRequest } from "@bibcode/contracts"; +import type { + DesktopBridge, + DesktopCloseReply, + DesktopCloseRequest, + DesktopUpdateState, +} from "@bibcode/contracts"; import { toastManager } from "../ui/toast"; @@ -82,8 +87,90 @@ export function isUpdateInstalling(): boolean { return updateInstall !== null; } +/** Kept per window, so a reload during an allowed install restores the block before any save. */ +const INSTALL_MARKER_KEY = "bibcode:update-install-allowed"; + +function readInstallMarker(): boolean { + try { + return window.sessionStorage.getItem(INSTALL_MARKER_KEY) === "1"; + } catch { + return false; + } +} + +function writeInstallMarker(on: boolean): void { + try { + if (on) window.sessionStorage.setItem(INSTALL_MARKER_KEY, "1"); + else window.sessionStorage.removeItem(INSTALL_MARKER_KEY); + } catch { + // An unusable sessionStorage means no marker: a reload then cannot restore the block. + } +} + +/** The desktop sets `phase` to protecting or installing only after the window allowed it. */ +function isRemoteInstallRunning(state: DesktopUpdateState | null | undefined): boolean { + return ( + state?.requestedBy != null && (state.phase === "protecting" || state.phase === "installing") + ); +} + +/** + * A confirmed state starts or ends the block. Only a cleared requester ends it: between the allow + * and the backup the phase still reads "available" with the requester set. + */ +function settleInstallState(state: DesktopUpdateState | null | undefined): void { + if (isRemoteInstallRunning(state)) blockForUpdateInstall(); + else if (state?.requestedBy == null && updateInstall !== null) endUpdateInstall(); +} + +const INSTALL_STATE_TIMEOUT_MS = 5_000; +const INSTALL_STATE_RETRY_MS = 2_000; + +/** + * Asks the desktop for its update state until it answers. A rejection or a timeout is not an + * answer: it changes nothing and asks again. Returns the function that stops asking. + */ +function askInstallState( + bridge: Pick, + onAnswer: (state: DesktopUpdateState | null | undefined) => void, + onAttemptFailed?: () => void, +): () => void { + let stopped = false; + let warned = false; + let timer: ReturnType | undefined; + const attempt = () => { + let settled = false; + const fail = (error: unknown) => { + if (settled || stopped) return; + settled = true; + clearTimeout(timer); + if (!warned) { + warned = true; + console.warn("[unsaved-file-edits] could not read the desktop update state", error); + } + timer = setTimeout(attempt, INSTALL_STATE_RETRY_MS); + onAttemptFailed?.(); + }; + timer = setTimeout(() => fail(new Error("no answer in time")), INSTALL_STATE_TIMEOUT_MS); + new Promise((resolve) => resolve(bridge.getUpdateState())).then((state) => { + // An answer after its own timeout or after stopping is stale. + if (settled || stopped) return; + settled = true; + stopped = true; + clearTimeout(timer); + onAnswer(state); + }, fail); + }; + attempt(); + return () => { + stopped = true; + clearTimeout(timer); + }; +} + function blockForUpdateInstall(): void { if (updateInstall !== null) return; + writeInstallMarker(true); const holds = [...sources].map((source) => source.holdSavingForLeave()); updateInstall = { holds, @@ -97,6 +184,7 @@ function blockForUpdateInstall(): void { /** The desktop install ended without a restart: saving and editing run again. */ function endUpdateInstall(): void { releaseLeaveHolds(); + writeInstallMarker(false); const install = updateInstall; if (install === null) return; updateInstall = null; @@ -207,10 +295,30 @@ const KEEPALIVE_INTERVAL_MS = 1_000; * while this subscription holds a request, and stopping it with one pending answers stay. */ export function respondToDesktopCloseRequests( - bridge: Pick, + bridge: Pick & + Partial>, ): () => void { - const { onCloseRequest, replyCloseRequest, onUpdateInstallEnded } = bridge; + const { onCloseRequest, replyCloseRequest, onUpdateInstallEnded, getUpdateState, onUpdateState } = + bridge; if (!onCloseRequest || !replyCloseRequest) return () => {}; + let stopAsking = () => {}; + const askAgain = () => + new Promise((resolve) => { + stopAsking(); + if (!getUpdateState) { + settleInstallState(null); + resolve(); + return; + } + stopAsking = askInstallState( + bridge as Pick, + (state) => { + settleInstallState(state); + resolve(); + }, + resolve, + ); + }); let active: { readonly requestId: number; readonly reason: DesktopCloseRequest["reason"]; @@ -240,11 +348,27 @@ export function respondToDesktopCloseRequests( void askBeforeLeaving(reason).then((answer) => { if (active !== current) return; // Only a remote install asks with "update"; it keeps running long after this answer. - if (answer === "proceed" && reason === "update") blockForUpdateInstall(); + if (answer === "proceed" && reason === "update") { + // An answer to a query sent before this allow would be stale. + stopAsking(); + blockForUpdateInstall(); + } finish(answer === "proceed" ? "allow" : "stay"); }); }); + const stopUpdateState = onUpdateState?.(() => { + stopAsking(); + }); + if (getUpdateState) { + // The reload of a window that allowed an install: hold saving before anything can run. + if (readInstallMarker()) blockForUpdateInstall(); + void askAgain(); + } else if (updateInstall === null) { + // Nothing can confirm an install on this desktop, so it counts as not installing. + writeInstallMarker(false); + } const stopEnded = onUpdateInstallEnded?.(() => { + stopAsking(); // The person forced the install from the native prompt while this question was open; a late // answer must not start a block that no signal will end. if (active?.reason === "update") { @@ -257,6 +381,8 @@ export function respondToDesktopCloseRequests( return () => { unsubscribe(); stopEnded?.(); + stopUpdateState?.(); + stopAsking(); if (active === null) return; finish("stay"); question?.answer("cancel"); diff --git a/apps/web/src/tauriDesktopBridge.test.ts b/apps/web/src/tauriDesktopBridge.test.ts index 55c899787..8fa23580c 100644 --- a/apps/web/src/tauriDesktopBridge.test.ts +++ b/apps/web/src/tauriDesktopBridge.test.ts @@ -237,6 +237,20 @@ function installTauriHarness(options?: { return { invoke, listen, listeners, unlisteners }; } +/** Tauri 2 rejects a command the desktop does not register with this exact text. */ +const UNKNOWN_UPDATE_STATE_COMMAND = "Command desktop_bridge_get_update_state not found"; + +function rejectCommand( + harness: ReturnType, + command: string, + error: unknown, +) { + const original = harness.invoke.getMockImplementation()!; + harness.invoke.mockImplementation((name: string, args?: Record) => + name === command ? Promise.reject(error) : original(name, args), + ); +} + async function installBridge(): Promise { const { tauriDesktopBridgeReady } = await import("./tauriDesktopBridge"); await tauriDesktopBridgeReady; @@ -1740,6 +1754,7 @@ describe("tauriDesktopBridge", () => { await expect(bridge.setWslOnly(true)).rejects.toThrow( "unsupported fallback command: desktop_bridge_set_wsl_only", ); + rejectCommand(harness, "desktop_bridge_get_update_state", UNKNOWN_UPDATE_STATE_COMMAND); await expect(bridge.getUpdateState()).resolves.toEqual({ enabled: false, status: "disabled", @@ -1766,6 +1781,74 @@ describe("tauriDesktopBridge", () => { expect(harness.invoke).toHaveBeenCalledWith("desktop_bridge_get_update_state", undefined); }); + it("getUpdateState rejects when the native call fails", async () => { + const harness = installTauriHarness(); + const bridge = await installBridge(); + const failure = new Error("boom"); + rejectCommand(harness, "desktop_bridge_get_update_state", failure); + await expect(bridge.getUpdateState()).rejects.toBe(failure); + }); + + it("getUpdateState answers the default when the desktop lacks the command", async () => { + const harness = installTauriHarness(); + const bridge = await installBridge(); + rejectCommand(harness, "desktop_bridge_get_update_state", UNKNOWN_UPDATE_STATE_COMMAND); + await expect(bridge.getUpdateState()).resolves.toMatchObject({ + enabled: false, + status: "disabled", + requestedBy: null, + }); + rejectCommand(harness, "desktop_bridge_get_update_state", { + code: "tauri_capability_unsupported", + method: "getUpdateState", + capability: "updater", + }); + await expect(bridge.getUpdateState()).resolves.toMatchObject({ + enabled: false, + requestedBy: null, + }); + }); + + it("other entries still fall back on a native failure", async () => { + const harness = installTauriHarness(); + const bridge = await installBridge(); + rejectCommand(harness, "desktop_bridge_get_client_settings", new Error("boom")); + await expect(bridge.getClientSettings()).resolves.toBeNull(); + }); + + it("a native failure through the real bridge keeps the restored block", async () => { + try { + const harness = installTauriHarness(); + const storage = new Map([["bibcode:update-install-allowed", "1"]]); + Object.assign(window, { + addEventListener: vi.fn(), + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + const bridge = await installBridge(); + vi.useFakeTimers(); + rejectCommand(harness, "desktop_bridge_get_update_state", new Error("boom")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { isUpdateInstalling, respondToDesktopCloseRequests } = + await import("./components/files/unsavedFileEdits"); + + const stop = respondToDesktopCloseRequests(bridge); + await vi.advanceTimersByTimeAsync(5_000); + const asked = () => + harness.invoke.mock.calls.filter(([name]) => name === "desktop_bridge_get_update_state") + .length; + expect(asked()).toBeGreaterThanOrEqual(3); + expect(isUpdateInstalling()).toBe(true); + expect(storage.get("bibcode:update-install-allowed")).toBe("1"); + stop(); + } finally { + vi.useRealTimers(); + } + }); + it("fails closed when browser catalog storage and event subscription are unavailable", async () => { const localStorage = { getItem: vi.fn(() => { diff --git a/apps/web/src/tauriDesktopBridge.ts b/apps/web/src/tauriDesktopBridge.ts index 12b4f5f74..84138fd86 100644 --- a/apps/web/src/tauriDesktopBridge.ts +++ b/apps/web/src/tauriDesktopBridge.ts @@ -136,6 +136,29 @@ async function tauriInvokeOr( } } +/** + * Falls back only when the desktop does not know the command (an older desktop); any other + * failure rejects, so a caller can tell a failed query from an answer. + */ +async function tauriInvokeOrWhenUnsupported( + command: string, + args: Record | undefined, + fallback: () => T, +): Promise { + try { + return await tauriInvoke(command, args); + } catch (error) { + // Tauri 2 words an unregistered command this way (`tauri::webview`, `Command {command} not found`). + if ( + (typeof error === "string" && error.includes(`Command ${command} not found`)) || + isTauriDesktopCapabilityUnsupportedPayload(error) + ) { + return fallback(); + } + throw normalizeTauriDesktopError(error); + } +} + export class TauriDesktopCapabilityUnsupportedError extends Error { readonly code = "tauri_capability_unsupported"; @@ -655,7 +678,7 @@ function createTauriDesktopBridge( onUpdateInstallEnded: (listener: () => void) => tauriListen(UPDATE_INSTALL_ENDED_EVENT, () => listener()), getUpdateState: () => - tauriInvokeOr( + tauriInvokeOrWhenUnsupported( "desktop_bridge_get_update_state", undefined, defaultUpdateState, From 9a0ee01d46cdd8bc4019f4271c80179e9e9ee64e Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:44:48 -0300 Subject: [PATCH 009/115] fix(web): repaint a terminal created after host config once its font loads (#348) The font effect ran only on a font or visibility change, so a renderer created later (an AI terminal waiting for host config) was never repainted after the bundled font loaded. It now re-runs for each committed renderer epoch and re-measures the cell before the layout and repaint. Co-Authored-By: Claude Opus 5.5 --- .../ThreadTerminalPanel.interactions.test.tsx | 99 +++++++++++++++++++ .../src/components/ThreadTerminalPanel.tsx | 13 ++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index f0d8d41fe..d867d8e5e 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -1817,12 +1817,18 @@ describe("TerminalViewport mounted lifecycle", () => { terminal.clearTextureAtlas.mockClear(); fitAddon.fit.mockClear(); testState.fontLoad.mockClear(); + terminal._core._charSizeService.measure.mockClear(); fitAddon.fit.mockImplementation(() => { terminal.cols += 1; }); await view.setTerminalFontPreference({ mode: "custom", family: "Maple Mono" }); + // xterm measured at the assignment, before the font loaded; the load measures again. + expect(terminal._core._charSizeService.measure).toHaveBeenCalledOnce(); + expect(terminal._core._charSizeService.measure.mock.invocationCallOrder[0]).toBeLessThan( + fitAddon.fit.mock.invocationCallOrder[0]!, + ); expect(xtermState.terminals).toHaveLength(1); expect(terminal.dispose).not.toHaveBeenCalled(); expect(view.detachRendererSpy).not.toHaveBeenCalled(); @@ -4796,6 +4802,99 @@ describe("TerminalViewport mounted lifecycle", () => { }); }); + it( + "repaints a renderer created after host config once the bundled font loads", + { timeout: 30_000 }, + async () => { + testState.serverConfig = null; + let resolveFont: () => void = () => undefined; + testState.fontLoad.mockImplementation( + () => new Promise((resolve) => (resolveFont = resolve)), + ); + const props = viewportProps({ + terminalId: "term-codex-config-loading", + command: decodeTerminalLaunchCommand({ executable: "codex.exe", args: [] })!, + }); + const mounted = await mount(); + expect(xtermState.terminals).toHaveLength(0); + testState.resizeCommand.mockClear(); + + testState.serverConfig = { + availableEditors: ["vscode"], + environment: { platform: { os: "windows" } }, + }; + const timerCallStart = beginComponentTimerCapture(); + await act(async () => mounted.root.render()); + captureComponentTimers(timerCallStart); + elapseMountSettle(); + await flushAnimationFrames(); + await flushComponentFitTimers(); + const terminal = xtermState.terminals.at(-1)!; + const fitAddon = xtermState.fitAddons.at(-1)!; + const measure = terminal._core._charSizeService.measure; + measure.mockClear(); + fitAddon.fit.mockClear(); + fitAddon.proposeDimensions.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + await act(async () => { + resolveFont(); + await Promise.resolve(); + }); + + expect(measure).toHaveBeenCalledOnce(); + expect(measure.mock.invocationCallOrder[0]).toBeLessThan( + firstCallOrder(fitAddon.proposeDimensions, fitAddon.fit), + ); + expect(terminal.clearTextureAtlas).toHaveBeenCalledOnce(); + expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); + expect(testState.resizeCommand).toHaveBeenCalledOnce(); + }, + ); + + it( + "ignores a host config font load that resolves after its renderer was disposed", + { timeout: 30_000 }, + async () => { + testState.serverConfig = null; + let resolveFont: () => void = () => undefined; + testState.fontLoad.mockImplementation( + () => new Promise((resolve) => (resolveFont = resolve)), + ); + const props = viewportProps({ + terminalId: "term-codex-config-loading", + command: decodeTerminalLaunchCommand({ executable: "codex.exe", args: [] })!, + }); + const mounted = await mount(); + testState.serverConfig = { + availableEditors: ["vscode"], + environment: { platform: { os: "windows" } }, + }; + const timerCallStart = beginComponentTimerCapture(); + await act(async () => mounted.root.render()); + captureComponentTimers(timerCallStart); + const terminal = xtermState.terminals.at(-1)!; + expect(testState.fontLoad).toHaveBeenCalled(); + + await act(async () => mounted.root.render()); + expect(terminal.dispose).toHaveBeenCalled(); + const measure = terminal._core._charSizeService.measure; + measure.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + await act(async () => { + resolveFont(); + await Promise.resolve(); + }); + + expect(measure).not.toHaveBeenCalled(); + expect(terminal.clearTextureAtlas).not.toHaveBeenCalled(); + expect(terminal.refresh).not.toHaveBeenCalled(); + }, + ); + it("uses the current theme when a hidden Windows Codex terminal first attaches", async () => { testState.resolvedTheme = "dark"; const command = decodeTerminalLaunchCommand({ executable: "codex.exe", args: [] })!; diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx index 60eb139d3..330867971 100644 --- a/apps/web/src/components/ThreadTerminalPanel.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -2563,7 +2563,14 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; - if (!shouldRender || terminal === null || fitAddon === null) return; + // A renderer created after this effect last ran is repainted once its epoch commits. + if ( + !shouldRender || + terminal === null || + fitAddon === null || + rendererEpoch !== rendererEpochRef.current + ) + return; terminal.options.fontFamily = terminalFontFamily; const rendererGeneration = resizeRendererGenerationRef.current; @@ -2579,6 +2586,8 @@ export function TerminalViewport({ return; } + // xterm measured the cell before the font loaded, or not at all for an equal family. + remeasureTerminalCharSize(terminal); terminal.clearTextureAtlas(); rendererRef.current?.size("layout"); terminal.refresh(0, terminal.rows - 1); @@ -2587,7 +2596,7 @@ export function TerminalViewport({ return () => { cancelled = true; }; - }, [shouldRender, terminalFontFamily]); + }, [shouldRender, terminalFontFamily, rendererEpoch]); useEffect(() => { const terminal = terminalRef.current; From 1a69e3e477c550ebca18bce32e8f61993a4b829c Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:40:14 -0300 Subject: [PATCH 010/115] test(e2e): probe for a dead webview before every test under one recovery deadline (#353) The shared specs run in one worker, so the probe in `before` ran once for all of them. `beforeTest` now probes first, and the probe, stop, relaunch, session reload and mock restore share one 100 s deadline inside the 120 s test allowance. Failure evidence in `afterTest` tolerates a dead webview so the worker's later tests still reach their probe. Co-Authored-By: Claude Opus 5.5 --- .../desktop/e2e/support/app-lifecycle.test.ts | 208 +++++++++++++++++- apps/desktop/e2e/support/app-lifecycle.ts | 165 ++++++++++---- apps/desktop/e2e/wdio.conf.ts | 24 +- 3 files changed, 351 insertions(+), 46 deletions(-) diff --git a/apps/desktop/e2e/support/app-lifecycle.test.ts b/apps/desktop/e2e/support/app-lifecycle.test.ts index 605b8791b..8ccf462aa 100644 --- a/apps/desktop/e2e/support/app-lifecycle.test.ts +++ b/apps/desktop/e2e/support/app-lifecycle.test.ts @@ -1,10 +1,21 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +// @effect-diagnostics nodeBuiltinImport:off - The hook-order test reads the WDIO configuration as text. +// @effect-diagnostics globalTimers:off - The deadline tests time their steps with fake timers. +import * as NodeFS from "node:fs"; + +import { parseSync } from "vite-plus"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + DESKTOP_UI_RECOVERY_DEADLINE_MS, + DESKTOP_UI_TEST_TIMEOUT_MS, + type RecoveryDeadline, dispatchDesktopUiApplicationExit, isDeadDesktopUiWebviewError, isFinalDesktopUiSpec, recoverDeadDesktopUiWebview, + startRecoveryDeadline, + tolerateDeadDesktopUiWebview, + withinRecoveryDeadline, } from "./app-lifecycle.ts"; describe("dispatchDesktopUiApplicationExit", () => { @@ -80,7 +91,7 @@ describe("recoverDeadDesktopUiWebview", () => { probe: vi.fn(probe), isRunning: vi.fn(async () => running), stopApplication: vi.fn(async () => undefined), - relaunch: vi.fn(async () => undefined), + relaunch: vi.fn(async (_deadline: RecoveryDeadline) => undefined), }); it("does nothing when the probe answers", async () => { @@ -119,3 +130,196 @@ describe("recoverDeadDesktopUiWebview", () => { expect(dead.relaunch).toHaveBeenCalledOnce(); }); }); + +describe("recovery deadline", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const after = (ms: number, value: T): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); + const dead = () => Promise.reject(new Error("invalid session id")); + + it("a probe that never answers fails at the deadline and relaunches nothing", async () => { + const relaunch = vi.fn(async (_deadline: RecoveryDeadline) => undefined); + const recovery = recoverDeadDesktopUiWebview({ + probe: () => new Promise(() => undefined), + isRunning: async () => true, + stopApplication: async () => undefined, + relaunch, + }); + const outcome = expect(recovery).rejects.toThrow( + "The webview recovery ran out of its 100000 ms during probe.", + ); + await vi.advanceTimersByTimeAsync(100_000); + await outcome; + expect(relaunch).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("the stop and the relaunch share one deadline", async () => { + let remainingAtRelaunch = -1; + const recovery = recoverDeadDesktopUiWebview({ + probe: dead, + isRunning: async () => true, + stopApplication: () => after(60_000, undefined), + relaunch: (deadline) => { + remainingAtRelaunch = deadline.remainingMs(); + return after(50_000, undefined); + }, + }); + const outcome = expect(recovery).rejects.toThrow( + "The webview recovery ran out of its 100000 ms during relaunch.", + ); + await vi.advanceTimersByTimeAsync(100_000); + await outcome; + expect(remainingAtRelaunch).toBeGreaterThanOrEqual(39_000); + expect(remainingAtRelaunch).toBeLessThanOrEqual(40_000); + // The abandoned relaunch settles at 110 s. + await vi.advanceTimersByTimeAsync(10_000); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a recovery whose steps fit completes", async () => { + const recovery = recoverDeadDesktopUiWebview({ + probe: dead, + isRunning: async () => true, + stopApplication: () => after(10_000, undefined), + relaunch: () => after(30_000, undefined), + }); + await vi.advanceTimersByTimeAsync(40_000); + await expect(recovery).resolves.toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("an operation that settles first clears its timer", async () => { + await expect( + withinRecoveryDeadline(startRecoveryDeadline(1_000), "step", async () => 7), + ).resolves.toBe(7); + expect(vi.getTimerCount()).toBe(0); + }); + + it("the recovery deadline leaves room in the test allowance", () => { + expect(DESKTOP_UI_RECOVERY_DEADLINE_MS).toBeLessThanOrEqual( + DESKTOP_UI_TEST_TIMEOUT_MS - 20_000, + ); + }); + + it("relaunches again when the webview dies a second time in the same worker", async () => { + const relaunch = vi.fn(async (_deadline: RecoveryDeadline) => undefined); + const deps = { + probe: dead, + isRunning: async () => false, + stopApplication: async () => undefined, + relaunch, + }; + await expect(recoverDeadDesktopUiWebview(deps, startRecoveryDeadline())).resolves.toBe(true); + await expect(recoverDeadDesktopUiWebview(deps, startRecoveryDeadline())).resolves.toBe(true); + expect(relaunch).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("tolerateDeadDesktopUiWebview", () => { + it("failure evidence tolerates a dead webview", async () => { + await expect( + tolerateDeadDesktopUiWebview(() => Promise.reject(new Error("Unsupported result type"))), + ).resolves.toBeUndefined(); + await expect( + tolerateDeadDesktopUiWebview(() => Promise.reject(new Error("boom"))), + ).rejects.toThrow("boom"); + await expect(tolerateDeadDesktopUiWebview(async () => 7)).resolves.toBe(7); + }); +}); + +describe("wdio.conf.ts hooks", () => { + // Read as text: importing the configuration throws without a packaged binary. The installed + // TypeScript 7 has no compiler API, so the oxc parser Vite+ ships reads it. + const text = NodeFS.readFileSync(new URL("../wdio.conf.ts", import.meta.url), "utf8"); + const parsed = parseSync("wdio.conf.ts", text, { lang: "ts" }); + + interface AstNode { + readonly type: string; + readonly start: number; + readonly end: number; + readonly [key: string]: unknown; + } + const isNode = (value: unknown): value is AstNode => + typeof value === "object" && value !== null && typeof (value as AstNode).type === "string"; + const children = (node: AstNode): AstNode[] => + Object.values(node).flatMap((value) => + Array.isArray(value) ? value.filter(isNode) : isNode(value) ? [value] : [], + ); + // Visits every node with its ancestors, nearest last. + const walk = (node: AstNode, visit: (node: AstNode, ancestors: AstNode[]) => void) => { + const go = (current: AstNode, ancestors: AstNode[]): void => { + visit(current, ancestors); + for (const child of children(current)) go(child, [...ancestors, current]); + }; + go(node, []); + }; + const sourceOf = (node: AstNode): string => text.slice(node.start, node.end); + + const hookBody = (name: string): AstNode => { + let body: AstNode | undefined; + walk(parsed.program as unknown as AstNode, (node, ancestors) => { + const declarator = ancestors.at(-2); + if ( + node.type === "Property" && + sourceOf(node.key as AstNode) === name && + isNode(node.value) && + node.value.type === "ArrowFunctionExpression" && + isNode(node.value.body) && + node.value.body.type === "BlockStatement" && + declarator?.type === "VariableDeclarator" && + sourceOf(declarator.id as AstNode) === "config" + ) { + body = node.value.body; + } + }); + if (body === undefined) throw new Error(`The exported config has no ${name} hook.`); + return body; + }; + const callee = (call: AstNode): string => sourceOf(call.callee as AstNode); + + it("parses the configuration", () => { + expect(parsed.errors).toEqual([]); + }); + + it.each(["beforeTest", "before"])( + "the %s hook registers the probe before any browser command", + (name) => { + const first = (hookBody(name).body as AstNode[])[0]; + expect(first?.type).toBe("ExpressionStatement"); + expect(first === undefined ? undefined : sourceOf(first)).toBe( + "await ensureLiveDesktopUiWebview();", + ); + }, + ); + + it("the afterTest hook collects failure evidence through the tolerance", () => { + const evidence: { readonly call: string; readonly tolerated: boolean }[] = []; + walk(hookBody("afterTest"), (node, ancestors) => { + if ( + node.type === "CallExpression" && + ["browser.saveScreenshot", "browser.getPageSource"].includes(callee(node)) + ) { + evidence.push({ + call: callee(node), + tolerated: ancestors.some( + (ancestor) => + ancestor.type === "CallExpression" && + callee(ancestor) === "tolerateDeadDesktopUiWebview", + ), + }); + } + }); + expect(evidence.toSorted((a, b) => a.call.localeCompare(b.call))).toEqual([ + { call: "browser.getPageSource", tolerated: true }, + { call: "browser.saveScreenshot", tolerated: true }, + ]); + }); +}); diff --git a/apps/desktop/e2e/support/app-lifecycle.ts b/apps/desktop/e2e/support/app-lifecycle.ts index 0a69ee070..30491fd45 100644 --- a/apps/desktop/e2e/support/app-lifecycle.ts +++ b/apps/desktop/e2e/support/app-lifecycle.ts @@ -215,6 +215,58 @@ export function desktopUiPlatform(environment: NodeJS.ProcessEnv = process.env): return host === "darwin" ? "mac" : host === "win32" ? "win" : "linux"; } +/** `mochaOpts.timeout` of `wdio.conf.ts`: the allowance of one test and of each of its hooks. */ +export const DESKTOP_UI_TEST_TIMEOUT_MS = 120_000; +/** + * One bound for the probe, the stop, the relaunch, `reloadSession` and the mock restore, leaving + * 20 s of the hook's allowance for the rest of `beforeTest`. A single WDIO command may wait + * `connectionRetryTimeout` (120 s), so every step is raced against this instead. + */ +export const DESKTOP_UI_RECOVERY_DEADLINE_MS = 100_000; + +export interface RecoveryDeadline { + readonly budgetMs: number; + remainingMs(): number; +} + +export function startRecoveryDeadline( + budgetMs: number = DESKTOP_UI_RECOVERY_DEADLINE_MS, +): RecoveryDeadline { + const started = Date.now(); + return { budgetMs, remainingMs: () => Math.max(0, budgetMs - (Date.now() - started)) }; +} + +/** + * Settles with `operation`, or rejects once the deadline runs out first. A late operation is + * abandoned, not cancelled: its own caps (bounded by the deadline) end it. + */ +export function withinRecoveryDeadline( + deadline: RecoveryDeadline, + step: string, + operation: () => Promise, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error(`The webview recovery ran out of its ${deadline.budgetMs} ms during ${step}.`), + ), + deadline.remainingMs(), + ); + // An async wrapper turns a synchronous throw into a rejection, so the timer is still cleared. + (async () => operation())().then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + let relaunched: NodeChildProcess.ChildProcess | null = null; let relaunchCount = 0; @@ -223,7 +275,9 @@ let relaunchCount = 0; * and port, and opens a new WebDriver session on it. The WDIO service only restarts the app * between spec files, so each closing case of one spec gets its own app session here. */ -export async function relaunchDesktopUiApplication(): Promise { +export async function relaunchDesktopUiApplication( + deadline: RecoveryDeadline = startRecoveryDeadline(), +): Promise { const port = webDriverPort(); if (await isDesktopUiApplicationRunning(port)) { throw new Error("The packaged app is still running; it cannot be relaunched."); @@ -234,13 +288,16 @@ export async function relaunchDesktopUiApplication(): Promise { }); // The single-instance plugin hands a new launch to a copy that is still exiting, which then // ends both, so wait until no process runs this packaged binary. - const started = Date.now(); - while (runningBinaryPids(appBinaryPath).length > 0) { - if (Date.now() - started > 30_000) { - throw new Error("The previous packaged app was still exiting after 30 s."); + const exitCapMs = Math.min(30_000, deadline.remainingMs()); + await withinRecoveryDeadline(deadline, "exit wait", async () => { + const started = Date.now(); + while (runningBinaryPids(appBinaryPath).length > 0) { + if (Date.now() - started > exitCapMs) { + throw new Error(`The previous packaged app was still exiting after ${exitCapMs} ms.`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); } - await new Promise((resolve) => setTimeout(resolve, 250)); - } + }); const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR; if (!artifactDirectory) throw new Error("BIBCODE_E2E_ARTIFACT_DIR is required."); relaunchCount += 1; @@ -258,32 +315,44 @@ export async function relaunchDesktopUiApplication(): Promise { } finally { NodeFS.closeSync(log); } - const launched = Date.now(); - while (!(await isDesktopUiApplicationRunning(port))) { - if (relaunched.exitCode !== null) { - throw new Error(`The relaunched packaged app exited with ${relaunched.exitCode}.`); + const child = relaunched; + const serveCapMs = Math.min(90_000, deadline.remainingMs()); + await withinRecoveryDeadline(deadline, "serve wait", async () => { + const launched = Date.now(); + while (!(await isDesktopUiApplicationRunning(port))) { + if (child.exitCode !== null) { + throw new Error(`The relaunched packaged app exited with ${child.exitCode}.`); + } + if (Date.now() - launched > serveCapMs) { + throw new Error("The relaunched packaged app never served WebDriver."); + } + await new Promise((resolve) => setTimeout(resolve, 250)); } - if (Date.now() - launched > 90_000) { - throw new Error("The relaunched packaged app never served WebDriver."); + }); + await withinRecoveryDeadline(deadline, "reloadSession", async () => { + try { + await browser.reloadSession(); + } catch (error) { + // The service copies the old session's mocks onto the new one after it opens; the new app + // never had them, and each spec installs the mocks it needs again. + if (!(error instanceof Error) || !error.message.startsWith("Mock updates failed")) + throw error; } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - try { - await browser.reloadSession(); - } catch (error) { - // The service copies the old session's mocks onto the new one after it opens; the new app - // never had them, and each spec installs the mocks it needs again. - if (!(error instanceof Error) || !error.message.startsWith("Mock updates failed")) throw error; - } + }); // Drop the old app's mocks, or every later mock call retries them against the new app. - await browser.tauri.restoreAllMocks(); - await browser.waitUntil( - () => - browser.execute(() => { - const host = window as Window & { readonly wdioTauri?: { readonly execute?: unknown } }; - return typeof host.wdioTauri?.execute === "function"; - }), - { timeoutMsg: "The relaunched app's Tauri WebDriver plugin did not initialize." }, + await withinRecoveryDeadline(deadline, "mock restore", () => browser.tauri.restoreAllMocks()); + await withinRecoveryDeadline(deadline, "plugin wait", () => + browser.waitUntil( + () => + browser.execute(() => { + const host = window as Window & { readonly wdioTauri?: { readonly execute?: unknown } }; + return typeof host.wdioTauri?.execute === "function"; + }), + { + timeout: Math.min(20_000, deadline.remainingMs()), + timeoutMsg: "The relaunched app's Tauri WebDriver plugin did not initialize.", + }, + ), ); } @@ -316,33 +385,53 @@ export interface DeadWebviewRecovery { readonly probe: () => Promise; readonly isRunning: () => Promise; readonly stopApplication: () => Promise; - readonly relaunch: () => Promise; + readonly relaunch: (deadline: RecoveryDeadline) => Promise; } /** - * One trivial script before a spec file. When it fails with a dead-session error, the app is + * One trivial script before a test. When it fails with a dead-session error, the app is * relaunched once (after ending a process that survived its webview), so a webview crash fails - * only the spec that hit it. Returns whether it relaunched. + * only the test that hit it. Every step shares one deadline. Returns whether it relaunched. */ -export async function recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise { +export async function recoverDeadDesktopUiWebview( + deps: DeadWebviewRecovery, + deadline: RecoveryDeadline = startRecoveryDeadline(), +): Promise { try { - await deps.probe(); + await withinRecoveryDeadline(deadline, "probe", deps.probe); return false; } catch (error) { if (!isDeadDesktopUiWebviewError(error)) throw error; } - if (await deps.isRunning()) await deps.stopApplication(); - await deps.relaunch(); + if (await withinRecoveryDeadline(deadline, "liveness check", deps.isRunning)) { + await withinRecoveryDeadline(deadline, "stop", deps.stopApplication); + } + await withinRecoveryDeadline(deadline, "relaunch", () => deps.relaunch(deadline)); return true; } +/** + * Failure evidence from a webview that may have died: a dead-webview error costs only the + * evidence, so the failing test's own error stands and later tests still reach their probe. + */ +export async function tolerateDeadDesktopUiWebview( + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + if (isDeadDesktopUiWebviewError(error)) return undefined; + throw error; + } +} + async function stopRunningDesktopUiApplication(): Promise { const pid = desktopUiApplicationPid(); process.kill(pid, "SIGTERM"); await waitForDesktopUiApplicationExit(pid, 10_000).catch(() => process.kill(pid, "SIGKILL")); } -/** The per-spec-file probe of the shared app; see {@link recoverDeadDesktopUiWebview}. */ +/** The per-test probe of the shared app; see {@link recoverDeadDesktopUiWebview}. */ export function ensureLiveDesktopUiWebview(): Promise { return recoverDeadDesktopUiWebview({ probe: () => browser.execute(() => true), diff --git a/apps/desktop/e2e/wdio.conf.ts b/apps/desktop/e2e/wdio.conf.ts index e3cd53dd1..b80471998 100644 --- a/apps/desktop/e2e/wdio.conf.ts +++ b/apps/desktop/e2e/wdio.conf.ts @@ -9,11 +9,13 @@ import { refreshDesktopUiDocument } from "./support/document-navigation.ts"; import { resolveDesktopAppPath, type DesktopUiPlatform } from "./support/app-path.ts"; import { + DESKTOP_UI_TEST_TIMEOUT_MS, ensureLiveDesktopUiWebview, isDesktopUiApplicationRunning, isFinalDesktopUiSpec, requestDesktopUiApplicationExit, stopRelaunchedDesktopUiApplication, + tolerateDeadDesktopUiWebview, } from "./support/app-lifecycle.ts"; import { deferDesktopUiTestContextCleanupUntilExit, @@ -72,6 +74,8 @@ const desktopUiSpecFiles = ]; // The default specs share one app session. The closing spec ends the app, so it runs last in a // worker of its own, and the service starts the app again for it if a shared spec ended it. +// Because the shared specs run in one worker, `before` runs once for all of them and +// `beforeTest` carries the per-test dead-webview probe. const desktopUiSpecs = requestedSpec ? desktopUiSpecFiles : [desktopUiSpecFiles.filter((spec) => spec !== closingSpec), closingSpec]; @@ -169,10 +173,10 @@ export const config = { reporters: ["spec"], mochaOpts: { ui: "bdd", - timeout: 120_000, + timeout: DESKTOP_UI_TEST_TIMEOUT_MS, }, before: async () => { - // A webview that crashed in an earlier spec would fail this one with the same dead session. + // Worker start only: the plugin check below needs a live webview. `beforeTest` probes per test. await ensureLiveDesktopUiWebview(); const pluginReady = await browser.execute(() => { const host = window as Window & { @@ -191,6 +195,7 @@ export const config = { } }, beforeTest: async () => { + await ensureLiveDesktopUiWebview(); let setupStage = "connection reset and navigation"; try { await resetDesktopUiConnectionCache(); @@ -209,11 +214,18 @@ export const config = { ) => { // A closing case may have ended the app; there is no window left to record. if (!result.passed && (await isDesktopUiApplicationRunning())) { - await browser.saveScreenshot(screenshotPath(`failure-${test.title}`)); - NodeFS.writeFileSync( - screenshotPath(`failure-source-${test.title}`).replace(/\.png$/, ".html"), - await browser.getPageSource(), + // A webview that died in the test cannot answer; a throwing hook would skip the worker's + // remaining tests before their probe could relaunch the app. + await tolerateDeadDesktopUiWebview(() => + browser.saveScreenshot(screenshotPath(`failure-${test.title}`)), ); + const pageSource = await tolerateDeadDesktopUiWebview(() => browser.getPageSource()); + if (pageSource !== undefined) { + NodeFS.writeFileSync( + screenshotPath(`failure-source-${test.title}`).replace(/\.png$/, ".html"), + pageSource, + ); + } } }, after: async (_result: unknown, _capabilities: unknown, currentSpecs: ReadonlyArray) => { From 97a15d14091dbf25baec02d5e1a3533fb1a9954f Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:46:00 -0300 Subject: [PATCH 011/115] test(web): a same-size return of the terminal mount repaints (#348) Co-Authored-By: Claude Opus 5.5 --- .../ThreadTerminalPanel.interactions.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index d867d8e5e..29be35b5b 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -3566,8 +3566,17 @@ describe("TerminalViewport mounted lifecycle", () => { mount.style.width = ""; xtermState.proposedDimensions = { cols: 80, rows: 24 }; - await view.resizeContainer(); - await view.flushFrame(); + terminal.refresh.mockClear(); + // The same-size return repaints, though nothing is sized (#348). + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await view.resizeContainer(); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTime(50)); + } finally { + vi.useRealTimers(); + } + expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); expect(view.resizeSpy).not.toHaveBeenCalled(); expect(terminal.cols).toBe(80); From d8794e2eea6b857e733e02e23aabbbac24d2caaf Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:48:38 -0300 Subject: [PATCH 012/115] fix(chat): reopen the model picker with an empty search (#358) The popover keeps its content mounted while closed, so the search typed before survived. Key the content by opening, counted on every closed-to-open change of the open state, so the trigger, the composer shortcut and the controlled prop all start a fresh search. Co-Authored-By: Claude Fable 5.2 --- .../chat/ProviderModelPicker.real.test.tsx | 150 ++++++++++++++++++ .../chat/ProviderModelPicker.test.tsx | 84 +++++++--- .../components/chat/ProviderModelPicker.tsx | 10 ++ 3 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/chat/ProviderModelPicker.real.test.tsx diff --git a/apps/web/src/components/chat/ProviderModelPicker.real.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.real.test.tsx new file mode 100644 index 000000000..d5cd3d77e --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.real.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom + +import { ProviderInstanceId, type ServerProvider } from "@bibcode/contracts"; +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { ProviderInstanceEntry } from "../../providerInstances"; +import type { ModelEsque } from "./providerIconUtils"; + +// Settings are stable between renders, as in the app; a new favorites array per call would +// make the content recompute its list on every render. +const settings = vi.hoisted(() => ({ favorites: [] as unknown[], update: () => undefined })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (selector: (value: { favorites: unknown }) => T): T => + selector({ favorites: settings.favorites }), + useUpdateClientSettings: () => settings.update, +})); + +import { ProviderModelPicker } from "./ProviderModelPicker"; + +const codex: ProviderInstanceEntry = { + instanceId: ProviderInstanceId.make("codex"), + driverKind: "codex" as ProviderInstanceEntry["driverKind"], + displayName: "Codex", + accentColor: undefined, + continuationGroupKey: undefined, + enabled: true, + installed: true, + status: "ready", + isDefault: true, + isAvailable: true, + snapshot: {} as ServerProvider, + models: [], +}; +const codexModels: ReadonlyArray = [ + { slug: "gpt-5", name: "GPT-5", shortName: "5" }, + { slug: "gpt-5-codex", name: "GPT-5 Codex", shortName: "Codex" }, +]; + +let container: HTMLDivElement; +let root: Root; +let originalGetAnimations: PropertyDescriptor | undefined; + +/** Owns `open` like the composer, whose imperative handle sets it without the picker. */ +function Composer({ model = "gpt-5" }: { model?: string }) { + const [open, setOpen] = useState(false); + return ( + <> + + ); diff --git a/apps/web/src/components/files/unsavedFileEdits.ts b/apps/web/src/components/files/unsavedFileEdits.ts index 85ce087ae..315b30d66 100644 --- a/apps/web/src/components/files/unsavedFileEdits.ts +++ b/apps/web/src/components/files/unsavedFileEdits.ts @@ -181,6 +181,17 @@ function blockForUpdateInstall(): void { notifyQuestionListeners(); } +/** The installing dialog's button: set while a close-request subscription can ask the desktop. */ +let askInstallStateAgain: (() => Promise) | null = null; + +/** + * Asks the desktop for its update state once more. Resolves after that attempt, answered or not; + * a failed attempt changes nothing and keeps asking. + */ +export function recheckUpdateInstall(): Promise { + return askInstallStateAgain?.() ?? Promise.resolve(); +} + /** The desktop install ended without a restart: saving and editing run again. */ function endUpdateInstall(): void { releaseLeaveHolds(); @@ -356,8 +367,10 @@ export function respondToDesktopCloseRequests( finish(answer === "proceed" ? "allow" : "stay"); }); }); - const stopUpdateState = onUpdateState?.(() => { + askInstallStateAgain = askAgain; + const stopUpdateState = onUpdateState?.((state) => { stopAsking(); + settleInstallState(state); }); if (getUpdateState) { // The reload of a window that allowed an install: hold saving before anything can run. @@ -383,6 +396,7 @@ export function respondToDesktopCloseRequests( stopEnded?.(); stopUpdateState?.(); stopAsking(); + if (askInstallStateAgain === askAgain) askInstallStateAgain = null; if (active === null) return; finish("stay"); question?.answer("cancel"); From da47502ec30cdf94fd29719d64b8474d8b1e570b Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:49:34 -0300 Subject: [PATCH 014/115] test(desktop): fail when a desktop event name exists on one side of the bridge only (#357) Co-Authored-By: Claude Opus 5.5 --- scripts/desktop-event-names.test.ts | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 scripts/desktop-event-names.test.ts diff --git a/scripts/desktop-event-names.test.ts b/scripts/desktop-event-names.test.ts new file mode 100644 index 000000000..0135d8f42 --- /dev/null +++ b/scripts/desktop-event-names.test.ts @@ -0,0 +1,40 @@ +// @effect-diagnostics nodeBuiltinImport:off - Repository event-name guard reads source files directly. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +const REPOSITORY_ROOT = NodePath.resolve(import.meta.dirname, ".."); +const WEB_BRIDGE = NodePath.join(REPOSITORY_ROOT, "apps/web/src/tauriDesktopBridge.ts"); +const RUST_SOURCES = NodePath.join(REPOSITORY_ROOT, "apps/desktop/src-tauri/src"); +const EVENT_NAME = /"(desktop:[a-z-]+)"/g; + +/** Names one side holds that are not events, each with the reason. */ +const WEB_ONLY: ReadonlySet = new Set(); +const RUST_ONLY: ReadonlySet = new Set([ + // A test hostname in `tailscale.rs` (an invalid URL port), not an event name. + "desktop:invalid-port", +]); + +function eventNames(text: string): Set { + return new Set([...text.matchAll(EVENT_NAME)].map((match) => match[1]!)); +} + +function rustText(): string { + return NodeFS.readdirSync(RUST_SOURCES, { recursive: true, encoding: "utf8" }) + .filter((path) => path.endsWith(".rs")) + .map((path) => NodeFS.readFileSync(NodePath.join(RUST_SOURCES, path), "utf8")) + .join("\n"); +} + +describe("desktop bridge event names", () => { + it("every desktop event name exists on both sides of the bridge", () => { + const web = eventNames(NodeFS.readFileSync(WEB_BRIDGE, "utf8")); + const rust = eventNames(rustText()); + expect(web.size).toBeGreaterThan(0); + expect(rust.size).toBeGreaterThan(0); + + const missingInRust = [...web].filter((name) => !rust.has(name) && !WEB_ONLY.has(name)); + const missingInWeb = [...rust].filter((name) => !web.has(name) && !RUST_ONLY.has(name)); + expect({ missingInRust, missingInWeb }).toEqual({ missingInRust: [], missingInWeb: [] }); + }); +}); From 1d5beda5227850980f977bf41219518b26b81b79 Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:49:59 -0300 Subject: [PATCH 015/115] feat(web): keep a content-free development record of terminal size decisions (#348) A ring of the last 200 sizing and repaint decisions (trigger, generation, mount box, fitted and applied grid, role, pixel ratio, font status, renderer, request and outcome, repaint), off by default and enabled only in development from window.__bibcodeTerminalSizeDiagnostics. It records no ids and nothing written to the terminal. Co-Authored-By: Claude Opus 5.5 --- .../ThreadTerminalPanel.interactions.test.tsx | 56 +++++++++++ .../src/components/ThreadTerminalPanel.tsx | 92 +++++++++++++++++-- .../terminalSizeDiagnostics.test.ts | 53 +++++++++++ .../src/components/terminalSizeDiagnostics.ts | 78 ++++++++++++++++ 4 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/terminalSizeDiagnostics.test.ts create mode 100644 apps/web/src/components/terminalSizeDiagnostics.ts diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index 29be35b5b..533902302 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -707,6 +707,11 @@ import ThreadTerminalPanel, { TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS, TerminalViewport, } from "./ThreadTerminalPanel"; +import { + clearTerminalSizeDiagnostics, + readTerminalSizeDiagnostics, + setTerminalSizeDiagnosticsEnabled, +} from "./terminalSizeDiagnostics"; import { decodeTerminalLaunchCommand } from "../lib/terminalLaunchCommand"; import { useCenterPanelStore } from "../centerPanelStore"; import { openLink } from "~/browser/openLink"; @@ -3944,6 +3949,57 @@ describe("TerminalViewport mounted lifecycle", () => { expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); }); + describe("development size record (#348)", () => { + afterEach(() => { + setTerminalSizeDiagnosticsEnabled(false); + clearTerminalSizeDiagnostics(); + }); + + it("records size diagnostics without terminal content", { timeout: 30_000 }, async () => { + setTerminalSizeDiagnosticsEnabled(true); + clearTerminalSizeDiagnostics(); + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + await act(async () => view.emitOutput("secret-marker-value")); + await view.flushFrame(); + expect(view.fakeTerminal!.writes.join("")).toContain("secret-marker-value"); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await view.resizeContainer(); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTime(50)); + } finally { + vi.useRealTimers(); + } + + const records = readTerminalSizeDiagnostics(); + const layout = records.find((entry) => entry.trigger === "layout"); + expect(layout).toBeDefined(); + for (const [field, value] of Object.entries(layout!)) + expect(value, field).not.toBeUndefined(); + expect(layout).toMatchObject({ + role: "legacy", + fitted: { cols: 80, rows: 24 }, + applied: { cols: 80, rows: 24 }, + renderer: "standard", + }); + expect(records.some((entry) => entry.repaint === "done")).toBe(true); + expect(JSON.stringify(records)).not.toContain("secret-marker-value"); + }); + + it("records no size diagnostics while disabled", { timeout: 30_000 }, async () => { + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + await view.resizeContainer(); + await view.flushFrame(); + setTerminalSizeDiagnosticsEnabled(true); + expect(readTerminalSizeDiagnostics()).toEqual([]); + }); + }); + it("issues one resize when either exact dimension changes and preserves real transitions", async () => { const view = await mountViewport({ visible: true }); await view.flushFrame(); diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx index 330867971..fdb002ff8 100644 --- a/apps/web/src/components/ThreadTerminalPanel.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -103,6 +103,14 @@ import { remeasureTerminalCharSize, terminalHostHasArea, } from "./terminalSizing"; +import { + readTerminalSizeDiagnostics, + recordTerminalSizeDiagnostic, + setTerminalSizeDiagnosticsEnabled, + terminalSizeDiagnosticsEnabled, + type TerminalSizeDiagnosticRecord, + type TerminalSizeDiagnosticTrigger, +} from "./terminalSizeDiagnostics"; import { answersTerminalQueries, isUnownedUntilUsed, @@ -149,6 +157,13 @@ const terminalInputBindings = new Map(); // releases it alongside the retained input resources below. const dismissedTerminalSizes = new Map(); const TERMINAL_WRITE_INTERRUPTED = Symbol("terminal-write-interrupted"); +// A developer turns the size record on and reads it from the console (#348). +if (import.meta.env.DEV && typeof window !== "undefined") { + (window as { __bibcodeTerminalSizeDiagnostics?: unknown }).__bibcodeTerminalSizeDiagnostics = { + enable: setTerminalSizeDiagnosticsEnabled, + read: readTerminalSizeDiagnostics, + }; +} interface TerminalInputBinding { renderer: { @@ -993,6 +1008,11 @@ export function TerminalViewport({ const rendererRef = useRef<{ readonly sizeClaim: string; dismissSizeNotice(): void; + /** Records a decision made outside the renderer; callers check diagnostics are on. */ + diagnose( + trigger: TerminalSizeDiagnosticTrigger, + repaint: TerminalSizeDiagnosticRecord["repaint"], + ): void; fit(): void; render(signal: TerminalRenderSignal, claimOnAttach: boolean, exited: boolean): void; size(trigger: TerminalSizeTrigger): void; @@ -1618,14 +1638,40 @@ export function TerminalViewport({ environmentId, input: { threadId, terminalId, ...size, structuredResult: true }, }); - const requestTerminalResize = (rendererGeneration: number, dimensions: TerminalDimensions) => { - if (resizeRendererGenerationRef.current !== rendererGeneration) return; + // A development-only record of size decisions (#348): numbers and enums, never content. + const diagnose = ( + trigger: TerminalSizeDiagnosticTrigger, + fitted: TerminalDimensions | null, + request: TerminalSizeDiagnosticRecord["request"] = null, + repaint: TerminalSizeDiagnosticRecord["repaint"] = "none", + ) => + recordTerminalSizeDiagnostic({ + trigger, + rendererGeneration: resizeRendererGeneration, + mountWidth: mount.clientWidth, + mountHeight: mount.clientHeight, + fitted, + applied: !sizeOwnership + ? { cols: terminal.cols, rows: terminal.rows } + : appliedSize && { cols: appliedSize.cols, rows: appliedSize.rows }, + role: !sizeOwnership ? "legacy" : appliedSize?.sizeClaim === sizeClaim ? "owner" : "mirror", + devicePixelRatio: globalThis.devicePixelRatio, + fontStatus: document.fonts?.status ?? "unavailable", + renderer: webglLifecycleRef.current !== null ? "webgl" : "standard", + request, + repaint, + }); + const requestTerminalResize = ( + rendererGeneration: number, + dimensions: TerminalDimensions, + ): boolean => { + if (resizeRendererGenerationRef.current !== rendererGeneration) return false; const current = lastRequestedSizeRef.current; if ( current?.generation === rendererGeneration && terminalDimensionsEqual(current.dimensions, dimensions) ) - return; + return false; const request = { generation: rendererGeneration, dimensions }; lastRequestedSizeRef.current = request; void resizeTerminal(dimensions).then((result) => { @@ -1634,6 +1680,11 @@ export function TerminalViewport({ lastRequestedSizeRef.current !== request ) return; + if (terminalSizeDiagnosticsEnabled()) + diagnose("resize-answer", null, { + ...dimensions, + outcome: result._tag === "Success" ? "applied" : "failed", + }); if (result._tag === "Success") { resizeRetries = 0; return; @@ -1641,6 +1692,7 @@ export function TerminalViewport({ lastRequestedSizeRef.current = null; retryFailedResize(); }); + return true; }; const resizeRendererGeneration = resizeRendererGenerationRef.current + 1; @@ -1787,6 +1839,7 @@ export function TerminalViewport({ handleSizeTrigger(pending); }, settleRemaining); if (sizeOwnership) updateSizeNotice(); + if (terminalSizeDiagnosticsEnabled()) diagnose(trigger, fittedDimensions); return; } // A settled automatic trigger runs a deferred one now; an attach claim keeps its rule. @@ -1801,10 +1854,10 @@ export function TerminalViewport({ const wasAtBottom = followsOutput(); fitTerminalSafely(fitAddon); if (wasAtBottom) terminal.scrollToBottom(); - requestTerminalResize(resizeRendererGeneration, { - cols: terminal.cols, - rows: terminal.rows, - }); + const fittedGrid = { cols: terminal.cols, rows: terminal.rows }; + const sent = requestTerminalResize(resizeRendererGeneration, fittedGrid); + if (terminalSizeDiagnosticsEnabled()) + diagnose(trigger, fittedGrid, sent ? { ...fittedGrid, outcome: "pending" } : null); return; } // A claim already answered for this geometry waits for its acknowledgement; sending it @@ -1844,8 +1897,10 @@ export function TerminalViewport({ ) { layoutChangedWhileClaimPending = true; } + let sentClaim: TerminalSize | null = null; if (fitted && shouldClaim(fitted)) { const request = { ...fitted, sizeClaim }; + sentClaim = request; claimInFlight = request; awaitedAcknowledgement = null; claimNoticeSuppressed = true; @@ -1857,6 +1912,13 @@ export function TerminalViewport({ ) return; claimInFlight = null; + if (terminalSizeDiagnosticsEnabled()) + diagnose("resize-answer", fittedDimensions, { + cols: request.cols, + rows: request.rows, + // An older server answers null for a claim it took. + outcome: succeeded ? (result?.outcome ?? "applied") : "failed", + }); if (succeeded) { resizeRetries = 0; completeClaim(request, result); @@ -1881,6 +1943,12 @@ export function TerminalViewport({ ); } updateSizeNotice(fitted); + if (terminalSizeDiagnosticsEnabled()) + diagnose( + trigger, + fitted, + sentClaim && { cols: sentClaim.cols, rows: sentClaim.rows, outcome: "pending" }, + ); }; const applySize = ( size: TerminalSize | undefined, @@ -1991,6 +2059,12 @@ export function TerminalViewport({ if (appliedSize) dismissedTerminalSizes.set(terminalTargetKey, appliedSize); updateSizeNotice(); }, + diagnose( + trigger: TerminalSizeDiagnosticTrigger, + repaint: TerminalSizeDiagnosticRecord["repaint"], + ) { + diagnose(trigger, fittedDimensions, null, repaint); + }, }; rendererRef.current = renderer; @@ -2591,6 +2665,7 @@ export function TerminalViewport({ terminal.clearTextureAtlas(); rendererRef.current?.size("layout"); terminal.refresh(0, terminal.rows - 1); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("font", "done"); }); return () => { @@ -2854,7 +2929,9 @@ export function TerminalViewport({ } terminal.clearTextureAtlas(); terminal.refresh(0, terminal.rows - 1); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("repaint", "done"); }, 50); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("repaint", "scheduled"); }); }; const observer = new ResizeObserver(scheduleResize); @@ -2871,6 +2948,7 @@ export function TerminalViewport({ }; function handlePixelRatioChange() { watchPixelRatio(); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("pixel-ratio", "none"); scheduleResize(); } watchPixelRatio(); diff --git a/apps/web/src/components/terminalSizeDiagnostics.test.ts b/apps/web/src/components/terminalSizeDiagnostics.test.ts new file mode 100644 index 000000000..810ab3922 --- /dev/null +++ b/apps/web/src/components/terminalSizeDiagnostics.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, expect, it } from "vite-plus/test"; +import { + clearTerminalSizeDiagnostics, + readTerminalSizeDiagnostics, + recordTerminalSizeDiagnostic, + setTerminalSizeDiagnosticsEnabled, + type TerminalSizeDiagnosticRecord, +} from "./terminalSizeDiagnostics"; + +function record(rendererGeneration: number): Omit { + return { + trigger: "layout", + rendererGeneration, + mountWidth: 640, + mountHeight: 320, + fitted: { cols: 80, rows: 24 }, + applied: null, + role: "legacy", + devicePixelRatio: 1, + fontStatus: "loaded", + renderer: "standard", + request: null, + repaint: "none", + }; +} + +beforeEach(() => { + setTerminalSizeDiagnosticsEnabled(true); + clearTerminalSizeDiagnostics(); +}); + +it("size diagnostics keep the last 200 records, oldest first", () => { + for (let index = 0; index < 205; index += 1) recordTerminalSizeDiagnostic(record(index)); + const records = readTerminalSizeDiagnostics(); + expect(records).toHaveLength(200); + expect(records[0]!.rendererGeneration).toBe(5); + expect(records.at(-1)!.rendererGeneration).toBe(204); +}); + +it("size diagnostics record nothing while disabled", () => { + setTerminalSizeDiagnosticsEnabled(false); + recordTerminalSizeDiagnostic(record(1)); + setTerminalSizeDiagnosticsEnabled(true); + expect(readTerminalSizeDiagnostics()).toEqual([]); +}); + +it("size diagnostics clear the ring when disabled", () => { + recordTerminalSizeDiagnostic(record(1)); + expect(readTerminalSizeDiagnostics()).toHaveLength(1); + setTerminalSizeDiagnosticsEnabled(false); + setTerminalSizeDiagnosticsEnabled(true); + expect(readTerminalSizeDiagnostics()).toEqual([]); +}); diff --git a/apps/web/src/components/terminalSizeDiagnostics.ts b/apps/web/src/components/terminalSizeDiagnostics.ts new file mode 100644 index 000000000..fab34720a --- /dev/null +++ b/apps/web/src/components/terminalSizeDiagnostics.ts @@ -0,0 +1,78 @@ +import type { TerminalResizeOutcome } from "@bibcode/contracts"; +import type { TerminalDimensions, TerminalSizeTrigger } from "./terminalSizePolicy"; + +/** + * A development-only record of the terminal's sizing and repaint decisions (#348). It holds + * numbers and enums only: no ids, no paths and nothing written to the terminal. + */ +export const TERMINAL_SIZE_DIAGNOSTICS_CAPACITY = 200; + +export type TerminalSizeDiagnosticTrigger = + | TerminalSizeTrigger + | "font" + | "pixel-ratio" + | "resize-answer" + | "repaint"; + +export interface TerminalSizeDiagnosticRecord { + readonly at: number; + readonly trigger: TerminalSizeDiagnosticTrigger; + readonly rendererGeneration: number; + readonly mountWidth: number; + readonly mountHeight: number; + readonly fitted: TerminalDimensions | null; + readonly applied: TerminalDimensions | null; + readonly role: "owner" | "mirror" | "legacy"; + readonly devicePixelRatio: number; + readonly fontStatus: "loading" | "loaded" | "unavailable"; + readonly renderer: "webgl" | "standard"; + readonly request: { + readonly cols: number; + readonly rows: number; + readonly outcome: TerminalResizeOutcome | "pending" | "failed"; + } | null; + readonly repaint: "none" | "scheduled" | "done"; +} + +const ring: Array = Array.from({ + length: TERMINAL_SIZE_DIAGNOSTICS_CAPACITY, +}); +let next = 0; +let count = 0; +let enabled = false; + +export function clearTerminalSizeDiagnostics(): void { + ring.fill(undefined); + next = 0; + count = 0; +} + +/** Ignored outside development; disabling also clears the ring. */ +export function setTerminalSizeDiagnosticsEnabled(value: boolean): void { + if (!import.meta.env.DEV) return; + enabled = value; + if (!value) clearTerminalSizeDiagnostics(); +} + +export function terminalSizeDiagnosticsEnabled(): boolean { + return enabled; +} + +export function recordTerminalSizeDiagnostic( + record: Omit, +): void { + if (!enabled) return; + ring[next] = { at: performance.now(), ...record }; + next = (next + 1) % TERMINAL_SIZE_DIAGNOSTICS_CAPACITY; + count = Math.min(count + 1, TERMINAL_SIZE_DIAGNOSTICS_CAPACITY); +} + +/** Oldest first, a copy. */ +export function readTerminalSizeDiagnostics(): readonly TerminalSizeDiagnosticRecord[] { + const start = + (next - count + TERMINAL_SIZE_DIAGNOSTICS_CAPACITY) % TERMINAL_SIZE_DIAGNOSTICS_CAPACITY; + return Array.from( + { length: count }, + (_, index) => ring[(start + index) % TERMINAL_SIZE_DIAGNOSTICS_CAPACITY]!, + ); +} From 004e5e7c29bb0f03c30a622458a784dc5e8494fd Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:50:50 -0300 Subject: [PATCH 016/115] test(files): prove the remote-update toast action stays reachable over the installing dialog (#357) Co-Authored-By: Claude Opus 5.5 --- .../files/UnsavedChangesCloseDialog.test.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx b/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx index ebcfb9b18..8fd39b3b5 100644 --- a/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx +++ b/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx @@ -16,6 +16,11 @@ vi.mock("../ui/toast", async (importOriginal) => ({ ...(await importOriginal()), toastManager: { add: toastAdd }, })); +// The real toast viewport reads the route; no router is mounted here. +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useParams: ({ select }: { select?: (params: object) => unknown }) => select?.({}), +})); import { FileEditingSession } from "./fileEditingSession"; import { FileEditingSessionRegistry } from "./fileEditingSessionRegistry"; @@ -841,6 +846,77 @@ describe("UnsavedChangesCloseHost", () => { }); }); + it("the toast action is reachable while the installing dialog is open", async () => { + await act(async () => root.unmount()); + // The real toast module: its viewport and its manager, not the mocked `add`. + const toast = await vi.importActual("../ui/toast"); + const { remoteUpdateRequestToast } = await import("../RemoteUpdateRequestNotifier"); + let answer!: (state: unknown) => void; + let pushState!: (state: unknown) => void; + window.desktopBridge = { + onCloseRequest: () => () => {}, + replyCloseRequest: async () => {}, + onUpdateInstallEnded: () => () => {}, + onUpdateState: (listener: (state: unknown) => void) => { + pushState = listener; + return () => {}; + }, + getUpdateState: () => + new Promise((resolve) => { + answer = resolve; + }), + } as unknown as DesktopBridge; + const outside = document.createElement("textarea"); + container.append(outside); + const mount = document.createElement("div"); + container.append(mount); + root = createRoot(mount); + try { + await act(async () => + root.render( + + + , + ), + ); + const onManageDevices = vi.fn(); + // Added as `RemoteUpdateRequestNotifier` adds it, before the dialog opens. + await act(async () => { + toast.toastManager.add( + toast.stackedThreadToast({ + type: "info", + ...remoteUpdateRequestToast({ label: "Test User", detail: null }, "1.2.3"), + actionVariant: "outline", + actionProps: { children: "Manage devices", onClick: onManageDevices }, + }), + ); + }); + const action = await vi.waitFor(() => button("Manage devices"), { timeout: 30_000 }); + + await act(async () => + answer({ requestedBy: { label: "Test User", detail: null }, phase: "installing" }), + ); + await vi.waitFor(() => expect(dialogText()).toContain("Installing an update"), { + timeout: 30_000, + }); + // Control: the modal dialog does take the page beneath it out of reach. + await vi.waitFor( + () => expect(outside.closest("[inert], [aria-hidden='true']")).not.toBeNull(), + { timeout: 30_000 }, + ); + expect(action.isConnected).toBe(true); + expect(action.closest("[inert], [aria-hidden='true']")).toBeNull(); + await act(async () => { + action.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onManageDevices).toHaveBeenCalledOnce(); + } finally { + // The block lives in module state: end it for the tests after this one. + await act(async () => pushState?.({ requestedBy: null, phase: "idle" })); + delete window.desktopBridge; + } + }); + it("answers a desktop close request through the bridge", async () => { await act(async () => root.unmount()); const replies: [number, string][] = []; From c1d2a3bfeebb8b3bae5cccb21dce735339558c27 Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:52:05 -0300 Subject: [PATCH 017/115] fix(web): say the server kept this size when Fit to this window sends nothing (#348) A second press after the server declined the claim sent nothing and showed nothing. The size notice now adds "The server kept this size." in its existing status region until the applied size changes or a claim is sent. Co-Authored-By: Claude Opus 5.5 --- .../ThreadTerminalPanel.interactions.test.tsx | 39 +++++++++++++++++++ .../src/components/ThreadTerminalPanel.tsx | 7 ++++ 2 files changed, 46 insertions(+) diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index 533902302..d5c79caf3 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -9061,6 +9061,45 @@ describe("TerminalViewport shared by windows on two machines (#348)", () => { expect(document.activeElement).not.toBe(terminal.textarea); }); + /** Fit to this window, pressed twice against a server that declines the claim. */ + const fitDeclinedTwice = async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + focusableXterm(client); + testState.resizeCommand.mockImplementation(async () => + AsyncResult.success({ + outcome: "claimNotRegistered", + size: server.size(), + generation: null, + sequence: null, + } satisfies TerminalResizeResult), + ); + await browserClick(fitButton(client)!); + const requests = testState.resizeCommand.mock.calls.length; + await browserClick(fitButton(client)!); + return { host, client, requests }; + }; + + const sizeNotice = (machine: Machine) => + Array.from(machine.mounted.container.querySelectorAll('[role="status"]')).find((element) => + element.textContent?.includes("Sized for another window."), + ); + + it("says the server kept this size when Fit to this window sends nothing", async () => { + const { client, requests } = await fitDeclinedTwice(); + expect(sizeNotice(client)?.textContent).toContain("The server kept this size."); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + expect(document.activeElement).toBe(fitButton(client)); + }); + + it("drops the server kept this size sentence when the applied size changes", async () => { + const { host, client } = await fitDeclinedTwice(); + expect(sizeNotice(client)?.textContent).toContain("The server kept this size."); + server.resize(claimOf(host), 120, 40); + await server.deliver(); + expect(view(client)).toMatchObject({ grid: "120x40", notice: true }); + expect(sizeNotice(client)?.textContent).not.toContain("The server kept this size."); + }); + // Real-pair case 5 on builds before the authoritative protocol: after the client took the // size and the host took it back by a keypress, a click on the client did not take it back. it.each([ diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx index fdb002ff8..69d0025b1 100644 --- a/apps/web/src/components/ThreadTerminalPanel.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -1005,6 +1005,8 @@ export function TerminalViewport({ : null; // The size notice's scale (1 when the owner's grid fits unscaled), or null while hidden. const [mirroringSize, setMirroringSize] = useState(null); + // Fit to this window sent nothing: the server already kept this size (#348). + const [fitKept, setFitKept] = useState(false); const rendererRef = useRef<{ readonly sizeClaim: string; dismissSizeNotice(): void; @@ -1902,6 +1904,7 @@ export function TerminalViewport({ const request = { ...fitted, sizeClaim }; sentClaim = request; claimInFlight = request; + setFitKept(false); awaitedAcknowledgement = null; claimNoticeSuppressed = true; const finishClaim = (succeeded: boolean, result: TerminalResizeResult | null = null) => { @@ -1967,6 +1970,7 @@ export function TerminalViewport({ const dismissed = dismissedTerminalSizes.get(terminalTargetKey); if (size && dismissed && !terminalSizesEqual(dismissed, size)) dismissedTerminalSizes.delete(terminalTargetKey); + if (!terminalSizesEqual(appliedSize, size)) setFitKept(false); appliedSize = size ?? null; if (size?.sizeClaim != null) unownedUntilUsed = false; // A window attaching to a size the server left un-owned waits to be used as well. @@ -2052,6 +2056,7 @@ export function TerminalViewport({ fit() { const pending = claimInFlight; handleSizeTrigger("fit"); + setFitKept(claimInFlight === pending); // The sent claim hides the notice and its button, so focus moves where the keys go. if (claimInFlight !== null && claimInFlight !== pending) terminal.focus(); }, @@ -2530,6 +2535,7 @@ export function TerminalViewport({ if (disposed) return; disposed = true; updateMirroringSize(null); + setFitKept(false); resizeRendererGenerationRef.current += 1; lastRequestedSizeRef.current = null; @@ -3011,6 +3017,7 @@ export function TerminalViewport({ Sized for another window. {mirroringSize < 1 ? ` Shown at ${Math.floor(mirroringSize * 100)}%.` : ""} + {fitKept ? " The server kept this size." : ""}