Skip to content

fix(web): re-evaluate Plan-mode auto-open when the post-turn file list settles - #6842

Open
maxmilian wants to merge 8 commits into
nexu-io:mainfrom
maxmilian:fix/5352-auto-open-settle-reevaluate
Open

fix(web): re-evaluate Plan-mode auto-open when the post-turn file list settles#6842
maxmilian wants to merge 8 commits into
nexu-io:mainfrom
maxmilian:fix/5352-auto-open-settle-reevaluate

Conversation

@maxmilian

Copy link
Copy Markdown
Contributor

Closes #5352.

Follow-up to #5602 and #6755 — it keeps both, and fixes the race that is left
inside the handoff between them.

Why

The turn-end auto-open pass decides from ONE fresh: true file-list read taken
right after the daemon reports terminal status. The generated file does not
always appear in that read: the daemon's write, the chokidar event, its
coalescing window (80ms / 250ms) and the refetch behind it all settle on their
own clocks. Nothing revisits the decision afterwards, so index.html never
becomes an openable tab.

@lefarcen picked this shape in
#5352 (comment)
re-evaluate when the post-turn file list settles, rather than making the whole
turn-end path await a global settle point. This PR is that direction: #5602's
centralised turn-end sequencing is untouched.

What it changes

  • auto-open-file.ts: new pure reevaluateAutoOpenOnFilesSettled(). Given the
    turn's inputs and a settled file list it answers "open this now" / "keep
    waiting" / "stop". selectAutoOpenTurnArtifact and
    selectAutoOpenProducedArtifact are unchanged.
  • ProjectView.tsx: the completion path arms that request with the turn's own
    inputs, and every accepted file-list generation (plus every focus change)
    re-runs the decision for up to 15s.

Two things worth calling out, because they are what makes it actually work:

  1. The touched-name set is re-resolved per list. resolveAgentTouchedFileNames
    resolves names FROM the file list, so a list that is still missing
    index.html also fails to resolve the write event that produced it — and
    selectAutoOpenTurnArtifact then drops the file as "not touched" even after
    it lands. The request therefore stores a resolver, not a computed set. This
    is the claude-protocol half of the bug; codex (no write events) supplies
    an empty set and rides the pure mtime window.
  2. "The current best is already focused" does not end the watch. If the
    turn-end pass could only settle for plan.md, stopping there would mean
    never noticing index.html arriving in the next list. The deadline ends the
    watch; a focus move to something neither the turn nor its open request put
    there also ends it, so the user is never yanked out of a tab they chose.

What users will see

In Plan mode, after "generate the deliverable", the generated index.html
takes focus reliably instead of intermittently leaving the user on plan.md
(first generation) or on the stale tab (regeneration).

Surface area

apps/web only — auto-open selection + one ProjectView effect. No daemon, API,
schema, or persisted-state change. Nothing is opened that the turn-end pass
would not have opened, only later and against a list that has caught up.

Verification

Real daemon + web, --workers=1, no assertion or timeout was relaxed
(e2e/ui/real-daemon-run.test.ts, --grep "Plan mode"):

scenario before (85d2e489) after
[P1] Plan mode regeneration re-opens the existing generated HTML file 2 passed / 5 runs 12 passed / 12 runs
[P1] Plan mode generation turn auto-opens the generated HTML file 5 passed / 5 runs 11 passed / 12 runs (see below)
[P1] Plan mode daemon run … editable markdown plan 5 / 5 4 / 4

Every "before" failure is the same shape: expectProjectFilesToContain(page, projectId, ['index.html', 'plan.md']) passes, and then the index.html tab is
never found — the file exists, it just never becomes a tab. That is exactly the
handoff this patch re-runs.

Also: apps/web unit suite 615 files / 6422 passed / 1 expected fail / 11
skipped; tsc -b --noEmit clean; new pure-function coverage in
apps/web/tests/components/auto-open-file.test.ts (9 cases, 53 in the file).

Two things I want to flag rather than bury

  • One residual failure of a DIFFERENT shape (1 of 12, first-generation
    scenario).
    This is the second of the two failure modes you mentioned: the
    index.html tab is present but aria-selected stays false. That scenario
    types into the plan.md split editor before generating, so the active tab
    holds a manual-edit exit handler and
    FileWorkspace.afterActiveManualEditSettles() (FileWorkspace.tsx:1575) defers
    the activation behind settleManualEdit(...), where a false result or a
    superseding activation sequence drops it silently. That is a tab-activation
    gate, not a file-list race, and re-issuing the open request only queues
    behind the same gate — so the settle re-evaluation does not reach it. I did
    not widen this PR to touch tab activation — happy to open a separate
    issue/PR if you want it chased.
  • [P1] real daemon run treats an in-place artifact edit as produced work
    fails on my machine
    (producedFiles: [] vs ['real-daemon-smoke.html']).
    It fails 3/3 with this branch AND 3/3 on upstream/main with the branch
    stashed, so it is pre-existing here and untouched by this diff.

docs

docs/testing/e2e-coverage/status.md — the Plan-mode line now carries the
measured reliability wording instead of a bare "restored to a positive
regression" entry, with the rerun counts (bd6a624d: 3/4 and 2/8;
85d2e489: 2/4 after #6755 removed the markers) and this fix's 12/12.

One note on the red-signal bar

You asked that #6708's red coverage stay in place until this goes green
without loosening assertions. By the time I started there was no marker left to
preserve: #6755 (028bde50a, @AmyShang-alt, 2026-08-12, about 16 hours before
I began) had already removed both markers from these two specs —

-  test.fail(true, 'Plan generation persists index.html but does not auto-open the generated deliverable.');
-  test.fixme(true, 'Blocked by #5352: generation does not reliably open or refocus index.html.');

— and the same PR rewrote the status.md line you pointed me at, moving it
from the "remaining gaps" section into the "restored to a positive regression"
list. The bug itself survived that change: on 85d2e489, with the markers
gone, regeneration still failed 3 of 5 runs, so those two specs were sitting on
the prerelease gate as intermittently-red positive tests.

I did not add the markers back. Reversing a decision your side made 16 hours
earlier is your call, not mine, so I left the specs as #6755 has them and am
flagging it here instead. The enforceable half of your bar is intact: no
assertion and no timeout was loosened to get these green.

…t settles

The turn-end auto-open pass decides from one `fresh: true` file-list read
taken right after the daemon reports terminal status. The generated file
does not always appear in that read — the daemon's write, the chokidar
event, its coalescing window and the refetch behind it settle on their own
clocks — and nothing revisits the decision, so `index.html` never becomes
an openable tab.

Keep nexu-io#5602's centralised turn-end sequencing and instead re-run the same
selection against each settled file list, until it resolves, its 15s
window expires, or focus moves somewhere the turn did not put it. Touched
file NAMES are re-resolved per list, because that resolution reads the
list too: a list still missing `index.html` also fails to resolve the
write that produced it.

Refs nexu-io#5352
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks @maxmilian — the re-evaluate-on-settle direction makes sense here, especially keeping the turn-end sequencing from #5602 intact while fixing the file-list race left in the handoff.

💡 To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor …):
Take over nexu-io/open-design#6842 until it merges — read https://raw.githubusercontent.com/nexu-io/looper/main/skills/pr-takeover/SKILL.md and follow it.

@lefarcen
lefarcen requested a review from nettee August 13, 2026 09:41
@lefarcen lefarcen added size/L PR changes 300-700 lines risk/medium Medium risk: regular code changes type/bugfix Bug fix needs-validation Runtime change detected; needs human or /explore agent validation. labels Aug 13, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

One quick follow-up from triage: this will need a manual QA pass before merge because it changes the Plan-mode auto-open behavior in the web app, so please hold off self-merging once reviews are green. Also, could you update the Surface area checklist and tick the user-visible box that matches this change? The rest of the write-up is already very clear.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: fa90a42 · Base: 4908baa

⚠️ 1 case(s) failed during diff generation; partial captures are shown below.

0 changed · 0 unchanged · 0 new without baseline · 1 failed

Capture or diff failures

  • visual-capture: No PNG screenshots found in /home/runner/work/_temp/visual-combined/visual-screenshots. Check the Playwright capture logs for details.

Visual diff is advisory only and does not block merging.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two blocking lifecycle races in the new settle watcher. The detailed inline comments explain how a delayed completion can outlive a newer turn and how a tab change during the async handoff can be mistaken for the baseline. Please add an ownership/focus witness before merging so the retry cannot move the user to stale output.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
],
autoOpenArtifactOptions,
);
const activeFileNameAtTurnEnd = openTabsActiveRef.current;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this snapshot is taken only after the completion continuation has awaited the post-run file refresh, artifact recovery/persistence, and the second refresh. The user can change tabs during that interval; that tab then becomes activeFileNameAtTurnEnd, so a later settled list treats the user's still-focused tab as the turn's baseline and reopens the resolved artifact instead of stopping. This contradicts the stated focus-move guard and can yank the user out of a tab they chose. Capture the focus/activation witness at terminal handoff before these awaits (or track an explicit user-vs-auto activation sequence) and only allow the settle request when that witness is unchanged; add a regression test that changes the active tab while the finalizer is waiting.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
// support file first — and nothing revisits it. Hand the same
// turn inputs to the settle watcher so the next file lists that
// land re-run the selection instead.
pendingAutoOpenSettleRef.current = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: clearing pendingAutoOpenSettleRef when a new turn starts does not protect this assignment because it runs in an unawaited completion continuation. Turn A can mark itself terminal, then a new send reaches the clear at line 6969 while A is still awaiting refresh/persistence; A subsequently reaches this line and installs its old producedFiles/resolver. The next turn's file-list generations then invoke evaluateAutoOpenSettle and can request Turn A's artifact while Turn B is running. Guard the continuation with a monotonic auto-open generation or run-owner token captured at send start, and check ownership immediately before arming/evaluating so an older finalizer cannot re-arm the watcher; add an overlap regression test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for the careful pass here. @nettee's two blocking comments on the current head look like the right next step: the settle watcher needs a turn-ownership guard so an older completion can't re-arm it, and the focus witness needs to be captured before the async completion handoff so a user tab switch isn't mistaken for the turn baseline.

Once those are addressed, happy to take another look.

Review on nexu-io#6842 found two lifecycle races in the settle watcher, both
rooted in the completion continuation being an unawaited async IIFE.

The focus witness was sampled after the post-run refresh, artifact
recovery/persistence and the second refresh had all been awaited. A tab
the user selected during that window therefore became the turn's own
baseline, so the focus-move guard read their choice as "where the turn
put focus" and reopened the artifact over it — the exact yank the guard
exists to prevent. Sample it at terminal handoff instead, before those
awaits. Moving the sample alone is not enough: the continuation opens a
recovered same-turn write itself, so the turn's own activations are now
recorded and travel with the request as turnOwnedFileNames rather than
reading as the user moving on.

Clearing the pending watch when a new turn starts also could not protect
the arming site, because an older finalizer reaches it after the clear
runs and reinstalls its own producedFiles/resolver; the newer turn's
file-list generations then drove the older turn's artifact into focus.
Add a monotonic generation bumped per send, checked immediately before
arming and again before every evaluation.

The immediate turn-end open predates this change and is left as is.

Refs nexu-io#5352
@lefarcen lefarcen added size/XL PR changes 700-1500 lines and removed size/L PR changes 300-700 lines labels Aug 14, 2026
@lefarcen
lefarcen requested a review from nettee August 14, 2026 12:56
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for pushing the follow-up. I re-requested @nettee on the new head since their current-head blockers were the ones this update was aimed at.

One quick PR-body follow-up while that review refreshes: could you add the Surface area checklist and a short Bug fix verification note for the red→green seam? The write-up already explains the race and the fix path clearly — those are the two remaining template pieces that would help the next pass.

@maxmilian

Copy link
Copy Markdown
Contributor Author

Thanks @nettee — both findings are real, and I reproduced the timing for each rather than taking them at face value. Fixed in 0b2de39.

① Focus witness sampled too late (ProjectView.tsx:7459)

Confirmed. The snapshot sat after await refreshProjectFiles({ fresh: true }), the artifact recovery/persistence step, and the second refresh — so a tab the user selected during that interval became activeFileNameAtTurnEnd, and the guard in auto-open-file.ts then read the user's own choice as the turn's baseline and let the reopen through. Exactly the case the commit message claimed was covered.

Moving the sample earlier alone isn't enough, though: the continuation itself moves focus during that window (requestOpenFile(sameTurnWrite.name)), so a naive pre-await witness would misread the turn's own auto-open as a user move and retire the watch early. So I took the second option you offered — an explicit user-vs-auto distinction:

  • activeFileNameAtTerminalHandoff is captured at terminal handoff, before the first await.
  • Every requestOpenFile the continuation issues records into a turnAutoOpenedFileNames set, and AutoOpenSettleRequest carries those as turnOwnedFileNames.
  • The guard now retires the watch unless the active file is one the turn itself put there.

I kept the field name activeFileNameAtTurnEnd but turned its comment into a hard contract: it must be sampled at terminal handoff, never after the post-run awaits, with a note on what breaks otherwise.

② Finalizer re-arming a watcher it no longer owns (ProjectView.tsx:7470)

Confirmed, and it's an ordinary interleaving rather than a narrow race: the clear at :6969 is synchronous on the send path, while the arming lives in an unawaited continuation, so Turn A reaching :7470 after Turn B's send is the expected order. ownsCurrentRun at :7328 doesn't cover it either — it's computed at terminal time, and B can start after that but before arming.

Added a monotonic autoOpenSettleGenerationRef: the send path bumps it, the finalizer captures its own generation, and arming happens only when the two still match (which also skips the immediate evaluateAutoOpenSettle() that follows arming). The stored request carries its generation and evaluateAutoOpenSettle re-checks before every evaluation, so a stale watcher can neither be installed nor consulted. auto-open-file.ts stays a pure function; the generation lives only in the component.

On the tests — worth describing, because my first two attempts were silently vacuous and both traps are easy to fall into:

  • Holding "the Nth file read" to block the continuation targets the wrong read: the send path takes its own pre-turn snapshot first, so the finalizer was never actually paused. The tests now arm a hold flag immediately before onDone and assert the continuation is parked there before proceeding.
  • If the turn's post-run read contains a previewable file, the immediate evaluate at arming time resolves it and retires the watch, so "must not open index.html" passes with and without the fix. The fixture now lands only a non-previewable .txt, so the turn-end selection finds nothing and the watcher is the only thing that can move focus.

Both regression tests fail on the pre-fix tree (ProjectView.autoOpenSettle.test.tsx: user changes tabs while the finalizer waits; superseded turn re-arms over a newer send), with failure messages that are the bugs' actual symptoms. There's also a deliberate positive control in that file — it passes before and after — proving the harness really does trigger the watcher; without it the two negative assertions could pass simply because nothing ever ran. Two unit cases in auto-open-file.test.ts cover the turn-owned / not-turn-owned split directly.

One thing I deliberately left alone: the immediate requestOpenFile(producedArtifactToOpen) on the completion path predates this PR (it's context, not added here). It has the same staleness question, but fixing it changes behaviour outside this PR's scope. Happy to put it behind the same generation gate in a follow-up if you'd like it — just say so and I'll open one rather than widen this diff.

Full apps/web suite is green on 0b2de39 (616 files, 6427 passed) and tsc --noEmit is clean. On CI, 17 checks have passed with no failures; the two UI P0 jobs are still running as I write this.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for writing this up so concretely. Reproducing both races, documenting why the first fixes were too weak, and adding the ProjectView-level regression coverage is exactly the kind of update that makes the next pass easier to trust.

I've already re-requested @nettee on this head, so the useful next step is their refreshed read on the watcher ownership/focus path.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian I reviewed every changed range, including the new pure selector, the ProjectView lifecycle, the regression tests, and the coverage note. The focused auto-open suites pass (58/58) and web typecheck is clean, but three stale-auto-open paths still bypass or outlive the new owner token; the inline comments show concrete interleavings that can focus an older turn's artifact during a newer turn or conversation. Please address those ownership boundaries before merging.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Location: apps/web/src/components/ProjectView.tsx RIGHT line 7155

Blocking: this unawaited per-write refresh is outside the new generation fence. If Turn A's refresh resolves after Turn B starts, completionSelectedAutoOpen can still be false and this branch calls requestOpenFile(decision.fileName) using A's file path; the settle-generation check later in the completion path does not protect this callback. A late A write can therefore steal focus during B even when the terminal watcher is skipped. Capture the run generation at send start and require it to match immediately before this request (ideally through one generation-aware auto-open helper), then add a delayed per-write overlap regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Inline comment could not be anchored: anchor_outside_complete_diff

// generation gives that finalizer a token to check against, so it can
// recognise that it no longer owns auto-open instead of reinstalling
// itself over this turn.
const autoOpenSettleGeneration = ++autoOpenSettleGenerationRef.current;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this token is advanced only after the async preflight/queue gates, and it is not tied to the active conversation. ProjectView intentionally stays mounted while activeConversationId changes, so if Turn A's finalizer is pending and the user switches to conversation B (or B is waiting in AMR preflight), this line has not run; A can later arm/evaluate its request against B's file-list generations and focus A's artifact. Advance/invalidate the owner before those awaits and clear/retire the pending request on conversation/authority changes (or store the conversation id in the request and reject mismatches). Add a switch-before-finalizer regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// behind unawaited awaits, so a newer send may already have taken
// over; arming here would then let THIS turn's artifact ride the
// NEXT turn's file-list generations into focus.
if (autoOpenSettleGenerationRef.current === autoOpenSettleGeneration) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this check is too late to protect the completion path. If Turn A is paused in the awaited refresh/persistence and Turn B starts, A can execute requestTurnOpenFile(sameTurnWrite.name) or requestTurnOpenFile(producedArtifactToOpen) above (and persistArtifact can call requestOpenFile) before reaching this if. The mismatch then skips only pendingAutoOpenSettleRef; the stale openRequest has already been sent and can focus A's file during B. Make every completion auto-open request generation-aware, including the artifact-persistence path, and add an overlap regression with a previewable post-run artifact.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for the update here. @nettee's latest pass is the one to drive from now: the remaining blockers are the three stale-auto-open ownership paths called out in that review, so once those are addressed on a new head we should be in a much clearer spot.

…oken

Second review round on nexu-io#6842 found three stale-auto-open paths that either
bypass or outlive the owner token the previous round introduced.

The token only fenced the settle watcher's arming site. Everything else a
run can ask focus for is unawaited too: the per-write refresh that lets a
file open while the run streams, and the completion continuation's own
opens, which sit above that arming site behind the post-run refresh and
artifact persistence. A run parked in any of them can resolve after a newer
send has taken over and pull focus to its own output during that newer run
— the same stale focus the watcher fence exists to stop, just arriving
directly instead of through the watcher. Route all of them through one
run-scoped helper that checks the token before requesting focus, and count
only a request that actually went out as turn-owned focus.

The token cannot cover a conversation switch: switching chats starts no new
turn, so nothing bumps it. ProjectView outlives conversation switches (only
ChatPane is keyed by the active conversation) and the file workspace is
project-scoped, so a watch armed in one chat stayed live and could focus
that chat's artifact underneath the new one. The pending watch now carries
its conversation and is retired as soon as the user leaves it.

Three regressions, each red without the fix: a superseded completion-path
open with a previewable post-run artifact, a delayed per-write refresh
settling during a newer run, and a conversation switch before the finalizer
lands. Each is paired with a positive control, so a "did not open"
assertion cannot pass by the mechanism never firing.

Refs nexu-io#5352
@maxmilian

Copy link
Copy Markdown
Contributor Author

Thanks @nettee — I went through all three interleavings against the code rather than the summary. Two are real and are fixed in 366ec5b; one I think does not hold, and I would rather say so than change the code on it.

Fixed — every auto-open a run can request now goes through one owner-token fence.

You are right that the token only guarded the arming site. The per-write refresh and the completion continuation are unawaited too, so a run parked in either can resolve after a newer send has taken over and pull focus to its own output — the same stale focus, just not arriving through the watcher. All of them now route through a single run-scoped helper that checks the token before requesting focus, and only a request that actually went out counts as turn-owned focus (a suppressed one moved nothing, so it must not widen the focus-move guard's "where the turn put focus" union).

That covers the completion path you flagged (requestTurnOpenFile(sameTurnWrite.name), requestTurnOpenFile(producedArtifactToOpen)) and the delayed per-write refresh. Two points of honesty about the per-write one: it is outside this PR's diff (GitHub could not anchor your comment for that reason) and the race predates this change — I took it anyway because the guard is one predicate in the same closure, and leaving it out would make this PR's ownership story incomplete.

Fixed — the watch is now conversation-scoped.

This one the token genuinely cannot cover, and the repo says so itself: ProjectView.tsx carries the comment "ProjectView outlives conversation switches (ChatPane is keyed by activeConversationId so it remounts when the user switches chats, but this component does not)". A conversation switch starts no new turn, so nothing bumps the generation, while the file workspace stays project-scoped — a watch armed in one chat could focus that chat's artifact underneath the new one for the rest of its 15s window. The pending watch now carries its conversation id and is retired as soon as the user leaves it (the active conversation is in the evaluator's dependencies, so the switch itself retires it rather than the next file list).

Not changed — advancing the token before the AMR preflight awaits.

The premise is right: the bump sits after checkAmrBalanceGate / resolveAmrPlan / the low-balance dialog. But I could not construct a harmful interleaving from it. While turn B is parked in preflight it has created no run and produced no files, so the only lists that can land are turn A's own trailing writes — re-evaluating A there is exactly what the settle watch is for, not stale output. The moment B actually starts a run it bumps the token and clears the pending watch. Moving the bump above those awaits would also invalidate A's legitimate watch for every send that never becomes a run (gate unavailable → parked, queued, or the user cancelling at the low-balance dialog), which reintroduces #5352 for that turn. The part of this comment that did bite — the conversation dimension — is fixed above. Happy to be shown an interleaving I have missed.

Scope — the recovery/replay completion paths.

"Make every completion auto-open request generation-aware" is broader than this PR can reach: there are four more completion sites in the reattach / replay / recovery branches (ProjectView.tsx around 5346/5390, 5684/5736, 5832/5854, 6243/6267) that live outside handleSend and have no run token in scope. Fencing those means lifting auto-open ownership to a component-level mechanism — a refactor I would rather do in its own PR than bolt onto this one. Same for persistArtifact's internal requestOpenFile. Happy to open that follow-up if you want it tracked.

Regressions. Three new ones, each red with the fix stashed and green with it:

  • superseded completion-path open, with a previewable post-run artifact (as you asked)
  • a delayed per-write refresh settling during a newer run
  • a conversation switch before the finalizer lands

Each is paired with a positive control, because the two guards I wrote in the previous round initially passed for the wrong reason — the mechanism under test never fired. The per-write harness parks the refresh with an explicit hold armed right before the tool result rather than counting file reads (the send path takes its own pre-turn snapshot, so a fixed read index parks the wrong request), and keeps the turn's post-run list free of previewable files so the released per-write refresh is the only thing that can move focus.

Local on 366ec5b: apps/web 6433 passed / 0 failed, tsc --noEmit clean, pnpm guard clean. CI is still running as I write this.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian I found four blocking ownership races in the new settle watcher. The focused selector/lifecycle tests, web typecheck, and guard pass, but these async paths can still focus stale output after a conversation or run handoff. Please address the inline comments and add the overlap coverage described there before merging.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// send has taken over and move focus to this run's output during that one.
// Requesting focus for a run that no longer owns auto-open is the same
// stale-focus bug whether it arrives through the watcher or directly.
const requestRunOpenFile = (fileName: string) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this generation-only gate still allows stale auto-open after a conversation switch. The generation is bumped when a new run starts, but switching from conversation A to B does not bump it, so a delayed per-write callback at the changed call sites below or a completion request can still pass this check for A and focus A's file in the shared workspace while B is active. The pending watcher has a conversation check, but these direct requests bypass it. Capture the run conversation and require it to match a live active-conversation ref before calling requestOpenFile, and route every run-owned opener (including persistence) through that check; add a switch-before-delayed-open regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

// diffing produced files). Nothing re-renders on arming alone,
// so evaluate once here rather than waiting for a further
// refresh that may never come.
evaluateAutoOpenSettle();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the conversation guard can be bypassed by the unawaited finalizer's stale closure. This call uses the evaluateAutoOpenSettle function captured by the handleSend render that started conversation A; after the user switches to B, that old evaluator still compares pending.conversationId against A's captured activeConversationId (the handleSend dependency list does not refresh this in-flight closure). If the settled artifact is already in projectFilesRef when the finalizer arms, it can request A's file before B's next effect clears the pending ref. Read the current conversation from a ref inside the evaluator (or call a current ref-held evaluator), and add a switch-before-finalizer test with the target already present in the accepted list.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
resolveTurnOptions: turnAutoOpenOptionsFor,
requestedFileName: producedArtifactToOpen ?? null,
activeFileNameAtTurnEnd: activeFileNameAtTerminalHandoff,
turnOwnedFileNames: [...turnAutoOpenedFileNames],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this snapshot only contains files opened through requestTurnOpenFile in the completion continuation. The earlier per-write callbacks were changed to requestRunOpenFile, so an accepted per-write open such as plan.md that settles after terminal handoff is absent from turnOwnedFileNames. When the settle list later selects index.html, activeFileName=plan.md is then neither the handoff baseline, requestedFileName, nor a recorded turn-owned name, so reevaluateAutoOpenOnFilesSettled retires instead of upgrading even though the same turn caused the focus. Keep a run-scoped set updated by every generation-accepted opener and snapshot that set here, with a delayed same-run per-write-then-settle regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

savedArtifactRef.current = sameTurnWrite.name;
completionSelectedAutoOpen = true;
requestOpenFile(sameTurnWrite.name);
requestTurnOpenFile(sameTurnWrite.name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the new wrapper only protects the sameTurnWrite branch. If that branch misses, the else path immediately calls persistArtifact, whose pointer and successful-write paths still call raw requestOpenFile (ProjectView.tsx:3617 and 3682). If turn A is awaiting that persistence while turn B starts, A can resolve the write and focus its artifact after the generation changes; this request never reaches requestRunOpenFile. Pass the generation-aware opener into persistArtifact or remove its internal auto-open and issue requestTurnOpenFile only after the guarded persistence completes, then add a delayed-persistence overlap regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for the detailed update here. @nettee's current-head review is the one to drive from now: the remaining blockers are the four ownership/focus races called out inline, so the useful next step is resolving those paths on a new head and then getting their refreshed pass.

Third review round on nexu-io#6842 found four stale-auto-open paths. Three are real
holes in the fence this PR builds; the fourth is declined on the PR comment.

The conversation guard added last round was read from the render's
`activeConversationId`, but the completion continuation calls the evaluator
captured by the render that STARTED the run — a dependency list cannot refresh
a closure already in flight. The guard therefore compared conversation A
against conversation A and passed, exactly at the arming site where the
finalizer arms and evaluates in one go, so a watch could still open A's file
underneath B before the next effect-driven evaluation retired it. Read the
conversation through a ref, and keep `activeConversationId` as an effect
trigger so leaving the chat still retires the watch at once.

The focus-move guard's turn-owned set was filled only by the completion
continuation's own opens. A per-write refresh is fire-and-forget and can land
its open after terminal handoff, so `plan.md` was neither the handoff baseline
nor a recorded activation, and when `index.html` finally settled the watcher
read the run's own focus as "the user moved on" and retired instead of
upgrading — the bug this PR exists to fix, re-entering through the fix's own
bookkeeping. Record every request the fence lets through, and hand the watch
the live set rather than a copy taken before those late opens exist.

`persistArtifact` auto-opens what it writes, straight to the unfenced opener.
The completion path delegates to it in the branch next to the one it fences,
and it is awaited, so a run parked on that write could still move focus after
a newer send took over. It now takes the caller's opener.

Three regressions, each red without its fix, each paired with a positive
control: an arming-site evaluation after a conversation switch, a per-write
open followed by a settled upgrade, and a persistence write that resolves
during a newer run.

Refs nexu-io#5352
@maxmilian

Copy link
Copy Markdown
Contributor Author

Thanks @nettee — three of these four are real holes in the fence, and one of them is in the guard I added last round. All three are fixed in 7654774; the fourth I'm declining, with reasoning below.

Fixed

  1. The conversation guard was bypassed by the finalizer's stale closure (your :7565). You're right, and it's worse than a partial gap: activeConversationId was the only non-ref value the evaluator read, and the completion continuation calls the evaluator captured by the render that started the run — no dependency list can refresh a closure that is already in flight. So the guard compared conversation A against conversation A and passed, precisely at the arming site where the finalizer arms and evaluates in one go. The later effect-driven evaluation does retire the watch, but only after focus has already moved. My own conversation test passed for the wrong reason: its post-run list had nothing previewable, so the arming-site evaluation selected nothing and the retirement was done by a fresh evaluator on a later list — the stale-closure path was never exercised. The guard now reads activeConversationIdRef, and activeConversationId moved to the effect's dependency list so leaving the chat still retires the watch immediately. New regression: retires at the arming site too when the user has left the conversation, paired with a positive control that proves the arming-site evaluation is what opens the file when nobody has left.

  2. The turn-owned set missed per-write opens (your :7556). Also right, and this one lets the original bug back in through the fix's own bookkeeping: a per-write refresh can land its open after the terminal-handoff witness is sampled, so plan.md was neither the baseline, nor the requested name, nor a recorded activation — and when index.html settled, the focus-move guard read the run's own activation as the user moving on and retired instead of upgrading. Two changes were needed, not one: the recording point moved down into requestRunOpenFile (the single place a request actually goes out, so requestTurnOpenFile is now a plain delegate), and turnOwnedFileNames became a live ReadonlySet instead of an array snapshot — a copy taken at arming time cannot see opens that happen afterwards, which is exactly the case at issue. Regression: upgrades to the settled artifact after a delayed per-write open of the same run.

  3. persistArtifact opened outside the fence (your :7448). Correct — the pointer path and the successful-write path both call the raw opener, both sit behind an await, and the completion path delegates to them in the branch right next to the one it fences. persistArtifact now takes an optional opener and the completion call passes the fenced one; the four reattach/replay/recovery call sites keep the default, consistent with the boundary from last round. Regression: does not open a superseded run's persisted artifact, with a positive control.

Declined, with reasoning

  1. Conversation-scoping every direct opener (your :7015). The premise is right — switching chats bumps nothing, so a live per-write or completion open is not conversation-aware. I don't think the conclusion follows for this PR:

    • It is pre-existing behavior in full. Before this PR both paths were raw requestOpenFile with no conversation awareness at all; nothing here made it worse.
    • The asymmetry with the settle watcher is deliberate. The watcher is a delayed retry that can fire up to 15s after the turn with no user-visible cause in between, so opening another chat's artifact from it is pure surprise — that's why I scoped it. A live open is the primary UX of the run the user themselves started, and the file workspace is project-scoped by design: both conversations share it.
    • The cost of the change is a real regression in the ordinary flow: start a run in A, switch to B to read something, come back — and the artifact tab never opened.

    If conversation-scoped focus is wanted, I think it belongs in the workspace's own ownership model rather than bolted onto this run fence, and it should be decided as product behavior rather than as a race fix. Happy to open a follow-up issue for it.

Follow-ups already noted on this PR (unchanged from last round): the four completion paths in the reattach/replay/recovery branches (5346/5390, 5684/5736, 5832/5854, 6243/6267) that are outside handleSend's closure and cannot reach a run token; plus the persistence opener at those same four call sites.

apps/web: 6438 passed / 0 failed / 11 skipped; tsc --noEmit clean; pnpm guard clean. Each of the three regressions is red with its own fix reverted and green with it, and each "did not open" assertion has a positive control so it cannot pass by the mechanism never firing. CI on 7654774 is still running as I write this.

@lefarcen
lefarcen requested a review from nettee August 17, 2026 09:29
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for the detailed write-up on 7654774. Re-requested @nettee on the new head since their blocking review is the one this update is targeting.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian The owner-token work is substantial, and the focused selector/lifecycle tests, web typecheck, and guard are green. Two blocking focus-ownership gaps remain in the changed path: the witness does not observe all workspace activations, and direct run-owned opens are not invalidated by a conversation switch. Both can still move a user's tab after they have chosen another workspace or chat; the inline comments describe the interleavings and regression coverage needed.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

});
// Mirror for the run-completion continuation, which reads the active tab
// long after the render that captured it.
const openTabsActiveRef = useRef<string | null>(openTabsState.active);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this focus witness is not the actual workspace focus. openTabsState.active contains only persisted tab activations, while FileWorkspace.activatePending() deliberately changes its local activeTab without calling onTabsStateChange for an unsaved sketch. Interleaving: the terminal handoff records notes.md; while the completion read is held, the user activates a pending sketch; when index.html settles, openTabsActiveRef still reports notes.md, so the watcher treats the handoff as unchanged and opens the generated file over the sketch the user chose. The same name-based witness cannot distinguish a later manual activation of a file that the run auto-opened. Propagate the effective workspace active tab plus a monotonic activation/user witness (including transient tabs) into the settle request, retire it after any user activation after handoff, and add a ProjectView regression that activates a pending sketch while the finalizer is held.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
// path's own opens.
const runAutoOpenedFileNames = new Set<string>();
const requestRunOpenFile = (fileName: string) => {
if (autoOpenSettleGenerationRef.current !== autoOpenSettleGeneration) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this helper fences only the monotonic send generation. Switching from conversation A to B does not increment that ref, and ProjectView intentionally stays mounted while the project-scoped FileWorkspace remains shared. A delayed per-write refresh or completion/persistence continuation from A can therefore pass this check after the switch and call requestOpenFile for A's file under B; the settle watcher's conversation check is bypassed because these direct paths call requestRunOpenFile instead. Capture runConversationId in this helper and require activeConversationIdRef.current === runConversationId immediately before the request (or invalidate the helper on conversation change), then add a delayed per-write/completion conversation-switch regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

The post-turn auto-open watch decided "has focus moved somewhere this turn
did not put it" from the active tab NAME, read through ProjectView's mirror
of the persisted tab state. That witness is blind in two ways, and both let
the watcher take focus away from a tab the user chose:

  * It does not see every activation. `FileWorkspace.activatePending` flips
    to an unsaved sketch locally and deliberately does not round-trip that
    through `onTabsStateChange`, so while the completion continuation is
    parked on its post-run read the user can activate a pending sketch and
    the mirror still reports the tab the turn recorded at handoff. When
    index.html settles, the guard finds focus exactly where the turn left it
    and opens over the sketch.
  * A name cannot say WHO activated it. A file this run auto-opened reads the
    same whether the run opened it or the user picked it afterwards, so the
    turn-owned union accepts a deliberate user choice as the run's own.

FileWorkspace now reports every activation it makes that the parent did not
ask for (`onUserActivateTab`), via one effect on `activeTab` rather than at
each of the twenty-odd activation sites, with the two parent-driven origins
— the `tabsState.active` hydration and the `openRequest` effect — marked so
a run's own auto-open cannot retire the watch it just armed. ProjectView
counts those reports in a ref, samples the count at terminal handoff beside
the focus witness, and `reevaluateAutoOpenOnFilesSettled` retires the watch
whenever it has moved. Anything unmarked counts as the user's: over-
reporting only ends a watch early, under-reporting steals focus.

The count is a required field on both the request and the evaluation context
so a future caller cannot drop the guard silently.

Tests: the pending-sketch interleaving at the ProjectView level (against the
existing positive control, which is what proves the watcher fires there at
all); both blind spots as unit cases plus a positive control that the count
is not a blanket off-switch; and the `onUserActivateTab` contract itself at
the FileWorkspace level, since ProjectView's suite mocks that component out.
@maxmilian

Copy link
Copy Markdown
Contributor Author

(1) ProjectView.tsx:2435 — you're right, and it's the same class of bug as last round's. Fixed in 8b294b7.

I verified the premise before touching anything, because the whole fix rests on it:

// FileWorkspace.tsx:1949
function activatePending(name: string) {
  // Pending sketches are not in tabsState.tabs — flip the local
  // activeTab without round-tripping through the parent.
  afterActiveManualEditSettles(() => setActiveTab(name));
}

It is the only activation path in that component that skips onTabsStateChange, and it is deliberate. It is also reachable from the tab strip: tabItemActions.activate (:3512) routes an unpersisted sketch there instead of to setPersistedActive. So your interleaving holds exactly as written — the witness keeps reporting notes.md, turnOwnsFocus passes, and index.html opens over the sketch the user just picked. That is #5352's original bug re-entering through my own bookkeeping, same as the turnOwnedFileNames snapshot last round.

Your second observation holds too: a name cannot distinguish "the run opened plan.md" from "the user picked plan.md afterwards". Its blast radius is narrower — it needs the run to have opened two or more files, since moving to anything else already retires the watch — but it is the same root cause, so one fix covers both.

The fix. The witness is a name, and a name can neither see every activation nor say who made it. So instead of recording more names, the workspace now reports user activations and the watch retires whenever that count has moved since terminal handoff:

  • FileWorkspace gained onUserActivateTab, fired from one effect on activeTab rather than at each activation site — there are twenty-odd of those, and a new one added later would silently go unreported. The two origins that are not the user (the tabsState.active hydration and the openRequest effect) are marked, so a run's own auto-open cannot retire the watch it just armed. Anything unmarked counts as the user's: over-reporting only ends a watch early, under-reporting steals focus.
  • ProjectView counts those reports in a ref — not state, since re-rendering it on every transient tab flip is exactly the round trip activatePending exists to avoid — and samples the count at terminal handoff, on the line after the focus witness, under the same contract.
  • reevaluateAutoOpenOnFilesSettled checks the count after the deadline and before any focus reasoning. Both the request field and the context field are required, so a future caller cannot drop the guard silently.

turnOwnedFileNames stays: while the count is unchanged, the guard still needs to know where the turn put focus.

Tests. Three new guards and a new positive control:

  • auto-open-file.test.ts: the pending-sketch shape (focus name looks entirely turn-owned; only the count sees the user), the same-name/different-actor shape, and a positive control asserting the count is not a blanket off-switch (unchanged count must still upgrade to index.html).
  • ProjectView.autoOpenSettle.test.tsx: the interleaving end to end, driven by calling onUserActivateTab() and deliberately not onTabsStateChange — exactly what activatePending does.
  • FileWorkspace.test.tsx: two contract tests. The ProjectView suite mocks FileWorkspace out, so "activatePending reports, and a parent-requested open does not" would otherwise be an untested assumption underneath the regression above — the same trap as the conversation test you caught last round. So it is pinned on the real component: creating a sketch with the write held open reports the activation while onTabsStateChange is never called at all, and a rerender carrying openRequest changes the persisted active tab without reporting.

Red-check, one revert at a time (per-item, since last round showed a green test can be green for the wrong reason): reverting only the evaluator check reddens all three guards and nothing else, with every positive control still green; reverting only the onUserActivateTab call reddens only the first contract test; reverting only the parent-request marking reddens only the second; and reverting only the sampling point — count read at arming instead of at terminal handoff — reddens the ProjectView regression. That last one is deliberate: it pins the sampling contract, not merely the presence of the guard.

One coverage gap worth stating rather than leaving for you to find: the two layers are pinned independently and joined by the prop contract. Reverting the FileWorkspace side does not redden the ProjectView regression, because that test calls the prop directly instead of going through the real component.

(2) ProjectView.tsx:7058 — this is the :7015 thread from last round, and I'm still declining it.

Same request, reframed: last round it was "scope every direct opener to the conversation", here it is "my conversation guard is being bypassed". The code under both comments is unchanged, and so is my reasoning — I'll restate it rather than just point back, since the framing is new:

  1. It is pre-existing behaviour. Before this PR both of those paths were raw requestOpenFile. This PR renamed one and fenced the run-ownership side of it; it did not introduce conversation exposure there.
  2. The asymmetry with the watcher is intentional, not an oversight. The watcher is a delayed retry — it can fire up to 15s after the turn, with no user-visible causal link, in a chat the user may have left. That is why it needs a conversation guard. A live open is the primary UX of the run the user just pressed send on, and the file workspace is project-scoped by design. Those two are not the same kind of action, and holding them to the same rule is what produces (3).
  3. The reverse cost is concrete, and it is the part still unanswered. Scope live opens to the conversation and "start a run in A → switch to B → come back to A → the artifact was never opened" becomes the new behaviour. That is a working flow turning into a broken one, in exchange for a bypass whose harm has not been demonstrated. This point was in my last reply and this round's comment does not engage with it — no interleaving, no scenario, and no argument that the trade lands the other way. If you have one I'll take it seriously and implement it; absent one, changing this is a product call about how project-scoped the workspace should be, not a defect fix.

Recorded as a follow-up with the line numbers, same as the other four.

apps/web: 6444 passed / 0 failed / 11 skipped; tsc --noEmit clean; pnpm guard clean. CI on 8b294b7 is still running as I write this.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian The owner fencing and settle-window coverage are materially stronger, and I verified the changed ranges with pnpm guard and the web typecheck. One blocking ownership gap remains: the new activation witness treats every openRequest as an internal request, although that same path carries user-initiated chat file opens. The inline comment gives a concrete interleaving where a user's choice is then overwritten by the settle watcher and a targeted fix/test.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}
// Marked so the reporter above does not read the parent's own auto-open as
// the user taking over — that would retire the very watch that issued it.
parentRequestedActivationRef.current = name;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this marker treats every openRequest as a parent-owned activation, but requestOpenFile is also used for user-originated ChatPane file-link and chip clicks, and the only user signal is emitted later from the activeTab effect. That creates concrete under-reporting paths: a chat click is marked here and never increments the count, while a pending-sketch activation can be held in afterActiveManualEditSettles until after the settle watcher evaluates. In either case ProjectView still sees the baseline persisted focus (or a run-owned name) and can open the later higher-ranked artifact over the user's choice. Carry explicit request-source metadata and mark only run-owned requests, or report user intent before the asynchronous activation gate; add regressions for a ChatPane open while the watcher is pending and for a delayed pending-sketch activation.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for the latest round here. @nettee's current-head blocking review is the one to drive from now: the remaining gap is that the new activation witness still treats user-initiated openRequest paths as run-owned, so the next useful step is carrying explicit request-source metadata (or an equivalent early user-intent signal) and covering the two regressions they called out.

Once that lands on a new head, we can re-check from there.

…t prop

`openRequest` carries two different kinds of open. It started as the chat
file-chip click and later picked up the run's auto-opens, so FileWorkspace
marking every arrival as parent-requested silently reclassified the user's
click: the settle watcher then saw "the user has not chosen anything" and
opened its own higher-ranked pick over the file she had just opened.

Requests now carry an explicit `source`, required at every ProjectView
callsite, and the workspace acts on it at the manual-edit settle gate —
the single choke point every activation in there passes through. Reporting
at the gate rather than off the landed activation also fixes the second
half: an open manual edit parks the activation, so a report derived from it
could arrive after the watcher's deadline, or never.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQKCR9FRJiexWWUpcoFLpm
@maxmilian

Copy link
Copy Markdown
Contributor Author

FileWorkspace.tsx:2061 — you're right, and the premise is stronger than the comment claims. Fixed in c0bdae5.

I checked the wiring before touching anything. ProjectView.tsx:11137 hands requestOpenFile straight to ChatPane as onRequestOpenFile, which is what a file link and a produced-file chip call on click. And the state this prop is built from says so itself:

// ProjectView.tsx:2507
// Routed to FileWorkspace — bumped whenever the user clicks "open" on a
// tool card, an attachment chip, or a produced-file chip in chat.
const [openRequest, setOpenRequest] = useState<{ name: string; nonce: number } | null>(null);

So this prop was the user's path first; the run's auto-opens were added to it later. Marking every arrival as parent-requested does not merely miss a case — it reclassifies the original meaning of the prop, and my own bookkeeping from last round is what made that reclassification load-bearing.

Your second path holds too, and it is the more general of the two. Every activation in the component funnels through afterActiveManualEditSettles, and that gate parks the activation until the active tab's manual edit has flushed. So a report derived from the landed activation is late by however long the edit takes — and if the edit never settles, it never arrives at all. That is not specific to pending sketches: setPersistedActive, openFile, activatePending, share, download and close all pass through the same gate.

The fix, in two parts, one per path.

  1. The request carries its own source. WorkspaceOpenRequest gained source: 'user' | 'internal', and requestOpenFile(name, source) takes it as a required second argument — all 22 ProjectView callsites had to state which they are. Run output, artifact recovery, brand extraction, the design-system opens and the settle watcher's own pick are 'internal'; ChatPane, the artifact share/download affordances, the brand-ready Preview button and the route file name are 'user'. DesignSystemFlow has its own workspace with no settle watch, so its opens are 'user' throughout.

  2. The report happens at the gate, not after it. afterActiveManualEditSettles(action, origin) reports the user's intent on entry, before the parked activation can land. That is one place rather than twenty-odd, and it is the same line for a chat click and for a delayed pending-sketch activation.

Why source is required but origin is not. Dropping source mislabels a caller in whichever direction that caller happened to be, so it has to be stated. origin defaults to 'user', and that default is the safe side of the asymmetry you and I have both been working from: over-reporting only retires a watch early, while under-reporting moves focus off a tab the user picked. A new activation site added later is therefore counted rather than silently dropped, and only the parent-driven entry points (hydration, browserOpenRequest, slide-nav, the persisted-tab fallback, designSystemEditRequest) opt out. I did consider making it required for symmetry and decided against paying twenty-odd callsites for a guard whose omission already lands safely.

parentRequestedActivationRef still marks both sources. It is no longer the thing that separates them — source is — but leaving it on the user path stops the activeTab effect from counting the same gesture a second time when the activation lands promptly.

Tests. Three new, in two places, because the two layers are separately observable:

  • New file FileWorkspace.userActivationTiming.test.tsx mocks FileViewer so it registers a manual-edit exit handler that never resolves — the gate's parking window held open for the whole test. Two cases: a user-sourced request is reported while the activation is still parked and onTabsStateChange has not fired at all; a run-sourced request in the same state is not reported.
  • ProjectView.autoOpenSettle.test.tsx covers the producing half — a chat chip click is stamped 'user' while the run's own auto-open is stamped 'internal' — which is all a suite that mocks FileWorkspace out can see.

Red-check, one revert at a time. Removing the gate's report reddens only the timing test's first case. Making openFile ignore origin reddens its second case and last round's parent-request contract test, and nothing else. Stamping ChatPane's opens 'internal' reddens only the ProjectView test; so does stamping the settle watcher's own pick (ProjectView.tsx:3847) 'user'.

Two things worth stating rather than leaving for you to find.

The delayed pending-sketch shape you named is fixed by the same line the timing test pins, since activatePending and a user openRequest both reach afterActiveManualEditSettles(…, 'user') — but I pinned it through the openRequest path only. Driving a real pending sketch while an HTML file is mid-manual-edit needs a trip through the Design Files tab to create the sketch, and that trip is itself an activation that settles the edit. So the sketch-shaped variant is covered by construction, not by its own test.

The ProjectView test originally waited for the settle watcher's second file list, which was stable alone and flaky under the full suite: AUTO_OPEN_SETTLE_WINDOW_MS is 15s of wall clock, and 6447 tests running concurrently can spend that before the assertion is reached. It now lets the post-run read carry the artifact so the watch's first evaluation, at arming, is what opens it — I confirmed that attribution from the actual call stack rather than assuming it, since the completion path produces an identical-looking request.

apps/web: 6447 passed / 0 failed / 11 skipped; tsc --noEmit clean; pnpm guard clean. CI on c0bdae5 is running as I write this.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian I found two remaining blocking stale-focus races in ProjectView.tsx. The focused auto-open tests, web typecheck, and guard pass locally, but these asynchronous paths can still focus output from an older run or conversation. Please address the inline comments and add the overlap regressions before merging.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
// path's own opens.
const runAutoOpenedFileNames = new Set<string>();
const requestRunOpenFile = (fileName: string) => {
if (autoOpenSettleGenerationRef.current !== autoOpenSettleGeneration) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this generation check does not include the active conversation. If run A is in the delayed per-write refresh, completion, or persistArtifact continuation, switching from conversation A to B leaves autoOpenSettleGenerationRef unchanged because no new send started. When A resumes, this check passes and requestOpenFile focuses A's artifact in the project-scoped workspace while B is active; the settle watch's conversation guard is bypassed by this direct opener. Capture runConversationId in the owner token and require activeConversationIdRef.current === runConversationId immediately before every run-owned request (or invalidate the token on conversation change), and add a delayed per-write/completion conversation-switch regression.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
setLiveArtifactEvents((prev) => appendLiveArtifactEventItem(prev, ev));
void refreshLiveArtifacts().then(() => {
if (ev.action !== 'deleted') requestOpenFile(liveArtifactTabId(ev.artifactId));
if (ev.action !== 'deleted') requestOpenFile(liveArtifactTabId(ev.artifactId), 'internal');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this live-artifact refresh callback bypasses the new run fence. refreshLiveArtifacts() is fire-and-forget, so after run A emits this event a newer send can advance autoOpenSettleGenerationRef; when the refresh resolves, this raw request still opens A's live:<id> tab during run B. It also is not added to runAutoOpenedFileNames, so if it lands after terminal handoff the settle watcher treats that run-owned focus as an external move and retires instead of upgrading the intended artifact. Route this callback through requestRunOpenFile and add a deferred-refresh overlap test so stale live-tab opens cannot cross a run boundary.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

…acts through the run

Two ways a run could still move focus somewhere it no longer owns.

The generation token fences a newer SEND, because only a send advances it.
Leaving the conversation advances nothing, so a run parked in a per-write
refresh, the completion continuation, or persistArtifact still passed the
token check on resume and opened its artifact into the workspace while
another chat was on screen. The settle watcher already refused to do that;
these direct openers were the way around it. Checked at the moment the
request goes out rather than at arming, since the whole point is that an
unbounded amount of time can pass in between.

The live-artifact open was issued as a raw request from inside a
fire-and-forget refreshLiveArtifacts() continuation, so nothing fenced it at
all. Routing it through the run's opener also records the name, which matters
separately: an unrecorded open landing after terminal handoff reads to the
settle watcher as the user choosing something else, and retires the watch
instead of upgrading.

The live-artifact regressions live in their own file. Parking
fetchLiveArtifacts needs a mock regime the sibling suite does not use, and
running both in one file made an unrelated per-write test go red under
full-suite load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQKCR9FRJiexWWUpcoFLpm
@maxmilian

Copy link
Copy Markdown
Contributor Author

Both blocking points are real. Fixed in cc37e270.

(1) ProjectView.tsx:7091 — the token fences a send, and leaving a conversation is not one

You're right about the asymmetry, and it is worth naming precisely: autoOpenSettleGenerationRef only moves when a send starts. Switching chats moves nothing, so a run parked in a per-write refresh, the completion continuation, or persistArtifact still passes the token check when it resumes — and then opens its artifact into a project-scoped workspace while another chat is on screen. The watcher already declines that case by comparing its request's conversation; these direct openers were the way around it.

The check goes in requestRunOpenFile, next to the token:

if (autoOpenSettleGenerationRef.current !== autoOpenSettleGeneration) return false;
if (activeConversationIdRef.current !== runConversationId) return false;

Evaluated where the request actually goes out rather than captured at arming, since the entire premise is that an unbounded amount of time passes in between.

This is not the :7058 thread I declined twice, and I want to be explicit about why, because on the surface they look like the same request. That one asked me to scope live opens to the conversation, and I argued it breaks a working flow: start a run in A, switch to B, come back to A, and the artifact was never opened. This one is about parked continuations resuming later — and my own argument last round was that a delayed path needs a conversation guard precisely because a live one does not. Holding that position while refusing this would be inconsistent, so I've taken it.

(2) ProjectView.tsx:7156 — confirmed, and the second effect you mention is the more subtle one

The callsite was a raw requestOpenFile inside void refreshLiveArtifacts().then(...), so it was fenced by nothing at all. It now goes through requestRunOpenFile, which fences it and — the part worth calling out — records the name in runAutoOpenedFileNames. Without that record, an open landing after terminal handoff reads to the settle watcher as the user having chosen something else, so it retires the watch instead of upgrading to the intended artifact. Same one-line callsite, two independent failures.

Tests

Four new, and the live-artifact pair is in a separate file, ProjectView.liveArtifactAutoOpen.test.tsx. That is not stylistic — see the note at the end.

  • autoOpenSettle.test.tsx: a delayed per-write refresh whose conversation the user left must not open. Its positive control is the existing "opens from a delayed per-write refresh while the run still owns auto-open" directly above it.
  • ProjectView.liveArtifactAutoOpen.test.tsx: a deferred live-artifact refresh crossing a run boundary must not open, plus a positive control that the same path does open while the run still owns auto-open.

Red-check, one revert at a time. Removing the conversation check reddens only the per-write conversation test. Restoring the raw requestOpenFile on the live-artifact callsite reddens only the deferred live-artifact test. Both positive controls stay green in both cases.

Three things from building this that you should have rather than not

The first version of the live-artifact test passed while proving nothing. It parked "the first fetchLiveArtifacts call", but other things read that list too, so the handler's own refresh resolved immediately and the deferred path was never exercised. Both tests passed either way; I only caught it because the red-check reddened a different test than the one I expected. It now arms the hold immediately before the event is emitted and asserts the handler's own read was the parked one — the same shape the completion-read helper in the sibling file already uses, and whose comment warns about exactly this.

Why the live tests are in their own file. With them alongside the other regressions, an unrelated per-write test went red under full-suite load while passing when the file ran alone. I isolated it by elimination rather than guessing: the base commit's full suite was green; keeping my source change but removing only the two new tests was green; moving them to their own file was green. So the interference was between the test files' mock regimes — parking fetchLiveArtifacts needs one the sibling suite doesn't use — and not in the change under review. Vitest isolates per file, which makes a separate file the fix rather than a workaround.

One reasoning error I made and corrected, since it could otherwise be hiding in the result. When the suite first went red I instrumented the new conversation check, re-ran, saw no failure and no log output, and briefly concluded the check wasn't responsible. That run simply hadn't reproduced the failure, so it established nothing. The elimination above is what actually settled it.

apps/web: 6449 passed / 0 failed / 11 skipped, run twice — this PR has already produced two "green alone, red in the full suite" cases, so a single green run isn't worth much here. tsc --noEmit clean; pnpm guard clean. CI on cc37e270 is running as I write this.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian I found one remaining blocking stale-focus race in the new ownership path. The generation/conversation checks and focused regressions pass at this head, but the owner witness stops when openRequest is enqueued; a queued internal request can still activate after the run or conversation that created it has been replaced. Please carry the owner metadata through request consumption and cover the deferred handoff.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Comment thread apps/web/src/components/ProjectView.tsx Outdated
const requestOpenFile = useCallback((name: string, source: WorkspaceOpenRequestSource) => {
if (!name) return;
setOpenRequest({ name, nonce: Date.now() });
setOpenRequest({ name, nonce: Date.now(), source });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the new fence ends at setOpenRequest(...); the request carries only name, nonce, and source, so it loses autoOpenSettleGeneration and runConversationId before FileWorkspace consumes it. FileWorkspace handles an internal request in its [openRequest] effect and can park afterActiveManualEditSettles on the active viewer. If run A enqueues here and the user starts run B or switches conversations before that manual-edit promise resolves, A's queued callback still passes the gate (it only checks the source tab and activation sequence) and focuses A's file; the checks at lines 7101–7102 have already run and cannot stop it. This is stale focus in the shared project workspace the owner token is meant to protect. Carry the owner generation/conversation (or a cancellation predicate) through WorkspaceOpenRequest and re-check it when the gate resolves/activation executes, then add a regression with a deferred manual-edit exit plus a run/conversation handoff.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for writing up the deferred-handoff case so concretely. I read the new head and the latest blocker, and the actionable next step still looks like @nettee's current-head point: the owner metadata needs to survive the queued openRequest all the way into FileWorkspace consumption, otherwise a deferred internal activation can still outlive the run or conversation that enqueued it.

The red web/UI checks line up with that path still being in play, so once that handoff is fenced and the overlap regression is in, this should be in a much clearer spot.

… runs

Every ownership check so far ran when the request was made. The workspace can
park an activation behind an unsettled manual edit for an unbounded time, and
the gate that releases it only re-checks the source tab and the activation
sequence — neither of which a run or conversation handoff changes. So a
request enqueued by run A could still focus A's file after the user started
run B or moved to another chat.

Requests now carry `isStillOwned`, re-asked at the moment the activation
executes. A predicate rather than data because only the requester knows what
owning it means: run-owned opens carry their generation and conversation, the
settle watch carries its conversation, and a user's click carries nothing —
a user's choice cannot go stale by waiting.

This closes the class rather than another instance of it: anything that
becomes false between enqueue and activation is now caught at activation.

Also polls the delayed per-write assertion instead of asserting on the tick
after the release. That race made a green guard look like a blocking one under
full-suite load, which is an expensive way to be wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQKCR9FRJiexWWUpcoFLpm
@maxmilian

Copy link
Copy Markdown
Contributor Author

Confirmed, and this one is a different shape from the previous rounds. Fixed in fa90a426.

You're right that the fence ended at setOpenRequest(...). Everything the requester checked — generation, conversation — was checked at request time, and the workspace can hold the activation behind an unsettled manual edit for an unbounded time. I have direct evidence of how unbounded: round 5's FileWorkspace.userActivationTiming.test.tsx holds that exact window open for a whole test with an exit handler that never resolves. So this is not a narrow interleaving; it's a window I had already demonstrated can be indefinite, with nothing re-checked at the end of it.

What the fix does. WorkspaceOpenRequest gained isStillOwned?: () => boolean, re-asked at the moment the activation actually executes:

const run = () => {
  if (isStillOwned && !isStillOwned()) return;
  action();
};

It lives inside afterActiveManualEditSettles rather than at the callsites, because that is the only place that knows whether the action ran immediately or waited — and it covers both paths from one spot.

Why a predicate rather than carrying the generation and conversation as data. Only the requester knows what owning its request means, and the three kinds differ:

  • run-owned opens carry generation and conversation (runStillOwnsAutoOpen, the same function the request-time check uses, so the two cannot drift apart);
  • the settle watch carries only its conversation — generation is not its fence, since a newer send retires the watch outright;
  • a user's click carries nothing, because a user's choice does not go stale by waiting.

Passing data would have meant teaching FileWorkspace how each of those is evaluated. This also means the guard closes the class rather than another instance of it: anything that becomes false between enqueue and activation is now caught at activation, whatever the reason.

Tests. The FileViewer mock in the timing suite now hands the test a resolvable exit handler instead of a permanently pending one — these two cases need the parked activation released after the handoff, to show it is dropped at that moment rather than merely still waiting.

  • drops a parked internal activation whose run lost ownership while it waited
  • lands a parked internal activation whose run still owns it — the positive control, without which "did not activate" would also pass if the parked activation simply never ran

Red-check: removing the re-check inside run() reddens only the first; not forwarding the predicate from the openRequest effect reddens the same one. The positive control stays green in both.

One thing you should know about, since it affected an earlier round

While verifying this I ran the full suite six times: green four times, and twice red — once on a test with no relationship to this change, and once on upgrades to the settled artifact after a delayed per-write open of the same run, which I added in round 4. The base commit was green on four runs, so I looked at my own test rather than assuming ambient noise.

It asserted on the tick after releasePerWriteRead(), which awaits a fixed number of microtasks — not a guarantee that the continuation behind it has reached its open. Under full-suite load that raced. The bad part is the failure mode: it produces an empty open-request list, which is exactly what a blocking guard produces. During round 6 I misread that signal and spent three full-suite runs eliminating the wrong cause. The assertion now polls with waitFor; three consecutive full runs are green.

I mention it because it means one earlier "the guard blocked it" reading in this PR's history was mistaken, and because if you see that test go red in future it is worth checking the assertion timing before the guard.

apps/web: 6451 passed / 0 failed / 11 skipped, three consecutive runs. tsc --noEmit clean; pnpm guard clean. CI on fa90a426 is running as I write this.

@nettee nettee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxmilian I found one blocking ownership gap in the settle-watcher activation handoff. The queued request can outlive a newer same-conversation send; the inline comment on ProjectView.tsx:3867 details the interleaving and the regression needed.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

requestOpenFile(
decision.openFileName,
'internal',
() => activeConversationIdRef.current === watchConversationId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this queued settle-watch request does not carry the turn generation into activation. Turn A can evaluate here, call requestOpenFile, and then have FileWorkspace park the request behind an active manual-edit exit. If Turn B starts in the same conversation while that promise is pending, handleSend increments autoOpenSettleGenerationRef and clears the pending watch, but this predicate still returns true because it compares only activeConversationIdRef. When the manual edit settles, FileWorkspace therefore opens Turn A's stale artifact during Turn B. That violates the newer-send ownership fence added above and can steal focus from the current run. Capture/check pending.generation (or pass the same runStillOwnsAutoOpen-style predicate through the watcher) at release time, and add a regression that defers manual-edit settlement, starts a same-conversation second send, then releases the gate and asserts no stale open.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

Copy link
Copy Markdown
Contributor

Thanks for spelling out the new head so concretely. @nettee's current-head blocker is still the one to drive from here: the queued internal open needs to carry the same-turn ownership check all the way through FileWorkspace consumption, otherwise a delayed same-conversation handoff can still replay stale focus after a newer send.

Once that handoff is fenced and the regression they asked for lands, this should be in a much clearer spot for the next pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-validation Runtime change detected; needs human or /explore agent validation. risk/medium Medium risk: regular code changes size/XL PR changes 700-1500 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-open generated HTML files after Plan mode completion

4 participants