fix: reduce compaction and cross-session responsiveness stalls

Aggregate compaction fragments before reactive SDK reduction while advancing
revision fences immediately and preserving native terminal and snapshot
authority. Keep task-tool shells keyed across payload updates and rescan bounded
membership so removals and replacements cannot leave phantom tools.

Isolate SSE callbacks and decode/dispatch diagnostics. Subscriber-local teardown
no longer destroys the healthy shared native client. Install HTTP cancellation
before preflight and fence late continuations without replaying or interrupting
already-admitted mutations. Preserve ownership and environment admission checks.

Move HTTP password derivation to async scrypt with serialized password changes
and login-authority fencing. Yield cooperatively in both structural-index phases
without changing snapshot/digest protocols; an individual SQLite step stays sync.

Add isolated red/green regressions, bounded browser/native qualification scripts,
independent reviews, source fingerprints and performance evidence for issue #824.
Synchronize the Git focus assertion with its async contract and make the opt-in
trace self-contained instead of serializing a tsx helper into the browser.

Validation includes 793 passing server tests and 6 skips after replay, UI/server/
Electron typechecks, UI/server/plugin builds, and private Windows native 2.0.21
fixtures. Renderer fixture median main-thread cost is 324.57 to 16.94 ms.
The latest full browser run remains 358 pass, 1 fail, 1 skip: its trace failure
was subsequently reproduced and corrected with a minimal Chromium check, but
the full suite has not been rerun. Store assertions use force-exit; natural
cleanup and Linux Electron to Windows LAN/HTTP with 2.0.19 remain unqualified.

Publish as a draft for review, not a universal resolution or issue-closing fix.
Refs #824.
This commit is contained in:
Pascal André 2026-10-02 00:40:45 +02:00
parent 366830f6b4
commit f6a18d00bd
No known key found for this signature in database
63 changed files with 6966 additions and 275 deletions

View file

@ -0,0 +1,121 @@
# Issue #824: native compaction renderer diagnosis
## Scope
This investigation covers the UI event path only. It does not exercise session
pruning, the shared daemon, provider traffic, Electron/Linux, or the remote
Windows backend. The fixture uses the real `SessionView`, native event
dispatcher, OpenCode Solid reducer, CodeNomad message store, and virtualized
transcript. Native client calls and `/api` traffic are isolated fakes.
The red reproduction is:
```powershell
$env:TEMP = 'C:\Users\Admin\AppData\Local\Temp\opencode'
$env:TMP = $env:TEMP
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/compaction-responsiveness.test.ts
```
## Confirmed behavior
A compaction for an inactive session still performs synchronous work in the
single renderer that owns the active session:
1. `SSEManager.handleEvent()` calls `handleInstanceInvalidation()` for every
native event.
2. `handleInstanceInvalidation()` calls `applyOpenCodeDataEvent()` before it
checks whether the session is active for visible projection.
3. `applyOpenCodeDataEvent()` creates/retains a per-session OpenCode Solid data
reducer and emits every `session.compaction.delta` into it.
4. The bundled `@opencode/client` reducer finds the running compaction and
appends every fragment to its `summary` in a Solid `produce()` update.
5. CodeNomad delays only `projectOpenCodeMessages()` to 250 ms. That avoids
repeatedly normalizing/hydrating the visible transcript, but it does not
coalesce the upstream reducer work or callback scheduling.
The active transcript stayed byte-for-byte unchanged in the fixture. The
timeline and virtualizer were therefore not the cause of the measured stall.
No `message.list` request occurred during any burst. The one `session.active`
call per sample comes from the synthetic terminal `compaction.failed` cleanup,
not from delta delivery.
### October 1 isolated Chromium sample
Each fragment was 1 KiB. The fixture schedules a zero-delay active-session
timer before dispatch and records browser Long Tasks. The final run reported:
| Delta events | Dispatch | Active timer delay | Long tasks |
| ---: | ---: | ---: | ---: |
| 4,096 | 14.1 ms | 14.2 ms | 0 |
| 8,192 | 21.9 ms | 22.0 ms | 0 |
| 16,384 | 42.6 ms | 42.8 ms | 0 |
| 32,768 | 82.5 ms | 82.6 ms | 83 ms |
CDP sampling attributed named self time primarily to
`applyOpenCodeDataEvent` (36.3 ms), OpenCode/Solid store access/update paths,
and `projectMessages` (11.8 ms). A large `(program)` bucket remained
unattributed by the sampling profiler. The near-linear count scaling points to
per-event reducer/callback overhead rather than a whole-transcript DOM redraw
or a superlinear summary concatenation in this corpus.
Two preceding runs also failed red at 32,768 fragments, with 80-85 ms timer
delay and one 82-85 ms Long Task. A 16,384-fragment run varied around the Long
Task boundary (approximately 42-70 ms), so the regression deliberately uses
the larger corpus and asserts behavior, not a comparative performance gain.
## Interpretation and limits
This is a confirmed renderer scalability defect: inactive compaction content
is reduced synchronously even though it cannot be displayed. It provides a
mechanism for cross-session UI interference and demonstrates delayed active
input/timers in the real UI stack.
It is not yet proof that normal EventSource delivery produces one callback
containing 32,768 events. The current browser transport invokes `onBatch` with
one event, while the reproduction deliberately presents a backlog directly to
the dispatcher to make callback work deterministic. Real network task
scheduling may interleave user input. The corpus is also 32 MiB of aggregate
summary fragments, larger than many compactions. Therefore this result must
not be presented as the sole cause of the reported multi-second freeze.
The test does not reproduce the daemon-side symptom where other native
sessions stop progressing, nor the Linux Electron to Windows HTTP persistence
after restart. Those require separate service/transport evidence. Native
compaction and CodeNomad's pruning RPC are unrelated paths here.
Current V2 UI events are `session.compaction.started/delta/ended/failed` plus
other granular `session.*` events. No current renderer handler for legacy
`session.updated`, `message.updated`, or `message.part.updated` was found.
## Smallest recommended product patch
Coalesce `session.compaction.delta` **before** the OpenCode Solid reducer, keyed
by instance and session, instead of only coalescing the later CodeNomad message
projection:
- append fragments to a plain pending accumulator;
- preserve an immediate revision fence on the first arrival so in-flight
native message reads cannot publish stale content;
- flush at most once per 250 ms to the reducer and visible projection;
- cancel the pending aggregate on `ended` because that event carries the
authoritative final text, and discard it on `failed`;
- clear pending aggregates on instance disposal/reconnect;
- do not materialize an inactive, unloaded transcript reducer solely for
compaction deltas. A later activation should hydrate authoritatively.
The regression should retain this two-session browser fixture and add exact
event-count instrumentation: thousands of inactive deltas must produce a
bounded number of reducer emissions/projections, zero message-list reads, no
active-transcript mutation, and no Long Task. Existing request-generation,
revert, pruning, rotation, and terminal-authority tests must remain green.
## Files added for reproduction
- `packages/ui/tests/browser/compaction-responsiveness.test.ts`
- `packages/ui/tests/browser/fixtures/compaction-responsiveness.tsx`
The initial timing-only test has since been replaced by deterministic reducer
and projection counters in `compaction-responsiveness.test.ts`. It is red on
the diagnosed implementation and green with the buffering correction. See
`issue-824-compaction-ui-fix.md` for the current gates and their limits. No shared
daemon, user profile, external network, or desktop restart is used.

View file

@ -0,0 +1,484 @@
# Issue #824 — freeze investigation
Mission baseline: `366830f6` (`origin/dev`, 2026-10-01). Integration worktree:
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance`, branch
`fix/issue-824-performance`. Confirmed corrections are integrated and independently
reviewed, but final acceptance is not green. On 2026-10-02 the user authorized
committing/pushing the mission changes and opening a draft PR to `dev` for review.
Publication does not imply acceptance, merge, installation, deployment or a shared
daemon restart.
## Scenarios and evidence policy
- Issue report: Electron Linux `0.20.0-dev-20260930-47f9e43e`, OpenCode 2.0.19,
remote Windows backend over HTTP. Submitting a prompt often locks the UI;
reopening the client and reconnecting to the same server/session does not help.
- Additional user report: native compaction makes the UI and other sessions
unresponsive, despite earlier optimization work.
- Distinguish renderer CPU, HTTP connection occupancy, backend event-loop work,
shared native/plugin work and provider waiting. Compaction and technical-content
pruning are different operations.
- Use synthetic databases, loopback fixtures and separately launched native
runtimes. Never test against the user's OpenCode database or mutate their
sessions. A local fixture reproduction is not proof of the reporter's exact
root cause or Linux/Electron qualification.
## Parallel investigations
1. Prior audit continuity and broader unaddressed performance risks.
2. Compaction events, transcript hydration and renderer responsiveness.
3. HTTP/SSE, prompt admission and shared backend responsiveness.
4. Coordinator: plugin history/index reads and event-loop fairness.
The previous audit's reports remain in the separate `audit-resume-integration`
worktree. Its changes must be checked against this baseline before reuse.
## Confirmed: structural-index batches monopolize their event loop
`packages/server/src/opencode/session-pruning/outline-index.ts` originally read
512 checkpoint headers synchronously with `.all()`, then projected assistant JSON
for 128 rows before yielding. A row count is not a time budget: tool output size
varies considerably, and SQLite evaluates nested assistant JSON synchronously.
The routine is invoked by the bundled plugin's `outline` RPC.
### Reproduction
`scripts/profile-outline-responsiveness.mts` creates only an in-memory SQLite
database. It runs the real structural-index reader with 512 assistant messages,
each holding one 1 MiB textual tool result. The heartbeat is a separate
`setImmediate` callback on the same loop. Its maximum observed gap includes
synchronous SQL/JS work and host scheduling, not remote-client paint latency.
```powershell
node --import tsx scripts/profile-outline-responsiveness.mts 512 1048576 --baseline
node --import tsx scripts/profile-outline-responsiveness.mts 512 1048576
```
`--baseline` bundles the exact pre-mission `366830f6` reader without replacing
working-tree files. Seeding and module compilation are outside the measured scan.
The stress corpus is deliberately large (~512 MiB of payload); this is not an
estimate of a typical session or the reporter's data size.
First baseline run: maximum gaps **542 / 522 / 720 ms**, total scans
1717 / 1969 / 2209 ms, six heartbeat turns each. A subsequent immutable-source
baseline run observed **615 / 755 / 712 ms**, totals 2066 / 2409 / 2273 ms.
Initial modified run: maximum gaps **13 / 14 / 14 ms**, totals
1829 / 2680 / 2225 ms, 199 / 262 / 224 turns. Windows, Node/SQLite; uncontrolled
concurrent host work. These observations demonstrate improved cooperation, not
reduced total CPU or an application frame-rate claim. No wall-clock CI threshold.
### Correction and regression contract
Iterate the same checkpoint headers and retain the exact digest/page protocol.
Yield during both header reads and JSON projection when the slice has consumed
8 ms or 128 rows, and check cancellation between rows. Preserve read snapshots,
ownership, staged-undo visibility, known checkpoints and response bounds.
`outline-responsiveness.test.ts` uses real SQLite with a view that charges a
deterministic simulated cost per payload access. It schedules an actual pending
event-loop cancellation in the header or projection phase. The original header
regression failed after **896 payload accesses** before cancellation could run.
Both new tests pass after the patch and assert the read transaction is released.
The existing nine navigation tests also pass; server typecheck passes.
A single SQLite step remains synchronous and cannot be interrupted by this
cooperative budget. This patch does not move native storage work to another
thread, reduce all JSON parsing, or establish that this defect alone causes #824.
Native plugin qualification and independent review are tracked separately.
## Confirmed: subscriber failures cross feature/connection boundaries
The prior audit found these defects, but both source blobs were unchanged in
`366830f6`. They were reproduced again against the current mission baseline.
- Browser `ServerEvents` dispatch stopped at the first throwing callback. A
wildcard subscriber could prevent the real SSEManager and typed subscribers
from receiving valid native events; open/status callback failures could prevent
reconciliation or abort restart. Three browser regressions failed before the
patch; all four (including normal FIFO/unsubscribe) pass after per-callback
exception isolation. Faults are diagnosed and never replayed.
- `OpenCodeSharedService.invalidateAfterStream` invalidated the shared client in
`finally`, including on subscriber-local abort, iterator return/throw or an
already-aborted subscription. Four regressions failed with the real SDK and
loopback HTTP/SSE source. All seven now pass: local lifetime changes leave the
healthy source/client alone, whereas genuine upstream EOF/transport errors
still invalidate the originating generation. Late cleanup cannot invalidate a
replacement; obsolete iterators cannot publish queued events. The fourteen
existing shared-service tests also pass.
Commands:
```powershell
node --import tsx --test packages/ui/tests/browser/event-subscriber-isolation.test.ts
node --import tsx --test packages/server/src/workspaces/subscriber-lifetime.test.ts packages/server/src/workspaces/opencode-service.test.ts
```
The complete private native fixture also passed on OpenCode 2.0.21 with the
production shared-service wrapper: abort/return/throw retain the same client,
relay shutdown leaves the independent subscriber usable, slow-recipient FIFO
remains correct, and proxy/history/navigation/pruning checks pass. Retained
fixture: `C:\Users\Admin\AppData\Local\Temp\opencode\codenomad-pruning-native-Atf4fG`.
The 48 adjacent relay/SSE/proxy/navigation unit tests pass as well. No user
daemon was stopped or restarted.
These are functional isolation fixes, not measured CPU speedups, and no
production callback failure from the reporter has been captured. They prevent
one feature/consumer's failure from disturbing unrelated consumers; they do not
isolate synchronous CPU-heavy callbacks into different threads.
Independent event-boundary review approved these corrections without a
functional blocker, but reproduced a diagnostic gap: a deferred Solid memo
failure can occur at the final shared-batch flush, after callback-level catches.
The transport then mislabeled it as invalid JSON. A small follow-up separates
JSON decoding from event dispatch in `event-source-handlers.ts`. Its new unit
and real-SSE/browser-derived-error tests failed before the patch; all nine focused
tests now pass. The same lot's native/typed handlers receive each event once,
and subsequent events continue. This does not isolate every reactive computation
or change shared batching. Final UI review will recheck it with compaction work.
Residual limits from `issue-824-event-review.md`: these diagnostics use the
existing `sse` debug logger (not enabled by default); a concurrent local abort and
physical source failure has not been deterministically reproduced. No speculative
connection-invalidation change or new logging pipeline is introduced for them.
## Confirmed: orphaned HTTP preflight observers
The real Fastify/loopback proxy reproduction in `issue-824-transport` held the
native session-ownership read for prompt and compact requests. Closing the HTTP
client did not cancel those reads: listeners were installed only after preflight.
An independent session still responded before the held reads were released.
This proves an orphaned-request defect, not a global server CPU freeze or why a
real native read stalls in the reporter's case.
The dedicated correction (`d9598e42`, following red diagnostic `1ece148c`)
installs per-request cancellation at entry, propagates it to supported reads,
retires the local observer of uncancellable shared work, and fences late
continuations before environment writes or forwarding. It never treats ordinary
POST body completion as disconnection, adds no new execution timeout and never
interrupts/replays an admitted native mutation. Twelve real HTTP tests and three
scope tests passed in the source worktree and again after integration, along with
65 adjacent proxy tests and server typecheck. Independent review accepts the patch;
see `issue-824-http-admission-fix.md`.
## Confirmed: HTTP password derivation blocks unrelated work
The prior audit's synchronous-scrypt concern was reproduced with real
Fastify/AuthManager/auth routes and a private v1 auth file. In five baseline runs,
a witness endpoint could not run until all eight invalid logins or four password
changes finished. The async-scrypt correction admits the witness while those
operations are pending. Descriptive five-process medians: login witness
**168.63 → 3.37 ms**, password-change witness **82.43 → 0.80 ms**. Timing is not
the CI assertion: the tests require witness progress before the burst completes.
Hash format/parameters and constant-time comparison are retained; password
changes are serialized and recover after persistence failure. The CLI/env
override hashes synchronously only at startup, before HTTP serving. The shared
libuv pool remains a finite resource, and there is no claim of reduced password
CPU or a fix for compaction. Eight focused tests passed repeatedly in the source
worktree. Integration added a deterministic regression for an old login crossing
a password change: the initial async patch returned 200 after the new secret was
stored. The corrected verifier checks that its auth record remains authoritative
after the derivation; it returns 401 without a cookie or replay. All nine auth
tests and server typecheck pass after integration. Independent review accepts it;
see `issue-824-auth-responsiveness.md`.
## Confirmed: nested task tools remount on every delta
The prior audit's task-renderer correction was requalified against the identical
`366830f6` source blob and integrated with a minimal +29/-18 diff. It retains
stable child-tool keys/shells while reading their current payload. Two baseline
regressions fail on shell identity and stale Markdown; 63 adjacent browser tests
pass after correction, covering disclosure, nested scroller state, in-place
revision, removal, copy, errors/retry and tool images.
Current bounded A/B (30 samples per variant, instrumentation off): median
main-thread duration **353.39 → 22.72 ms**. Instrumented controls show 80 tool-shell
removals/reinsertions per delta becoming zero. The 80 clones/reloads remain; no
claim is made about desktop frame rate or eliminating all task-render costs.
See `issue-824-render-recovery.md` for artifacts, failed setup runs excluded from
statistics, and source-drift caveats. Final combined validation must use a frozen
composition; the prior measurements do not establish that the whole concurrent
worktree was unchanged. Independent combined UI review confirmed a membership
defect: an authoritative page removing one tool part from a retained message
leaves a phantom key/count, potentially hiding a later replacement. The renderer
correction therefore was not accepted for final qualification. The follow-up now
reuses the existing bounded structural scanner at each session revision, retaining
the prior ordered key list when unchanged. Keyed `For` preserves retained shells
through removal/reordering. Five new regressions fail on the preimage and all
68 adjacent browser scenarios pass naturally after correction, including exact
membership/count, tool-to-text identity changes and truncation shrink. The scan
still caps at 10,000 units and 201 tool discoveries/200 displayed tools; scanning
IDs/types on every revision is the explicit correctness tradeoff. Zero remounts
per delta remain instrumentally verified, but prior CPU A/B figures do not
qualify this new postimage. Independent re-review now closes M1 with no residual
blocker: 13 renderer and nine copy/image cases pass freshly and naturally, with
zero remounts per delta. See
`issue-824-task-membership-fix.md` and `issue-824-ui-review.md`, finding M1.
## Confirmed: compaction payload reduction precedes visible throttling
The existing 250 ms throttle limited only visible projection; every native
fragment still entered the reactive SDK reducer, even for an inactive session.
The correction aggregates by instance/session before that reducer, with a timer
started by the first chunk (later chunks do not postpone it). Incoming fragments
still advance revision/read fences immediately. Same-session ordering and SDK
page boundaries flush pending text; terminal authority, reconnect, deletion,
revert, pruning and disposal cancel obsolete buffers. Never-loaded inactive
sessions keep authority markers rather than materializing summary payloads.
The acceptance tests now use exact emission counts and text, not the original
artificial 32 MiB Long Task threshold: 128 active chunks produce one interval
reduction instead of 128, eight chunks delivered in separate 10 ms tasks produce
one reduction, and 128 never-loaded inactive chunks produce no payload reduction
or history request. All five browser tests discriminate the exact baseline and
pass on the patch. Activation mid-compaction, loaded-inactive sessions and remount
preserve exact authoritative text. Eleven new reducer/lifecycle tests and 130
neighbors pass; 67 real-renderer/history browser cases pass naturally.
See `issue-824-compaction-ui-fix.md`. Unit store gates use the repository's
`--conditions=browser --test-force-exit` convention for persistent imported
timers; this is not evidence of natural process cleanup. Spaced dispatcher tasks
are not real EventSource/provider cadence qualification. Interleaved same-session
events/read boundaries intentionally reduce the coalescing ratio, and sustained
arrivals can delay the existing revision-stable activation catch-up. Independent
UI review accepts this coalescing within those limits, without a reproduced text,
ordering or fence defect. Frozen-source validation results are recorded below.
## Browser HTTP/1 isolation: bounded non-reproduction
Five real Chromium Windows → Fastify Windows loopback HTTP/1.1 cases pass:
prompt/compact crossed with page/context close, plus a separate background-read
budget case. One real multiplexed SSE and a held SDK session A preflight leave
input/switch, B reads, metadata and a control endpoint available. Abandonment and
reconnection retire old HTTP/SSE observers before the held handler is released;
late completions produce no forward, environment write or shared invalidation.
Five catalogue intents admit only two held reads; queued work from the destroyed
page does not resume. Held upstream handler closures and idle pooled sockets are
reported separately from outstanding HTTP observers.
See `issue-824-browser-http.md`: 5/5 standalone, 10/10 combined with event
isolation, and 15/15 neighboring server tests pass naturally. No new product
correction follows from this non-reproduction. CDP and Fastify both verify the
HTTP/1.1 protocol, but controls are a minimal Solid fixture, not the complete
transcript/composer; upstream is disposable HTTP with native-shaped responses,
not native compaction. Linux/Electron, LAN and native 2.0.19 remain unqualified;
the report records the isolated cross-host experiment and evidence to collect.
## Native service isolation: bounded non-reproduction
The private native compaction fixture completed twice on OpenCode 2.0.21/Windows.
While A's real summary response was deliberately gated for ~1.5 s, B accepted a
prompt, ran its model request and completed successfully before A was released.
Native info/get/activity remained available (~1.8–7 ms observed). Both sessions
were simultaneously running; A then completed compaction with 29 native deltas
and no compaction-failed event. During a separate 32 MiB outline scan in the real
plugin host, six native info reads completed before the scan ended (final run).
See `issue-824-native-compaction.md` for commands, evidence and limits. This first
scenario used only 24,557 bytes of outgoing summary context; the 32 MiB tool
corpus belongs to outline work, not A's compaction context. Large-context assembly
was then qualified separately: two private runs each at 2/8/32 MiB verify every
tool payload byte at the actual provider boundary (32 MiB request JSON:
33,612,826 bytes). Submission-to-provider headers grows to 225–291 ms at 32 MiB;
unrelated pre-arrival reads show transient latency up to ~85 ms. B nevertheless
completes before A reaches the provider in both 8/32 MiB pairs, and completes
before A's held response is released at every size. No continuous global freeze
or specific native CPU hotspot is established. Provider JSON parse time is
separately recorded, not attributed to native assembly. No speculative upstream
patch follows. No inference about 2.0.19, Linux/Electron, remote TCP or the
reporter's exact data follows from this bounded evidence.
## Validation checkpoint (not final combined qualification)
`node --import tsx --test "packages/server/src/**/*.test.ts"` completed naturally:
**776 tests, 770 pass, 6 skip, 0 fail**, about 32.8 s. UI and server typechecks
also passed at this checkpoint. Auth/proxy corrections developed in separate
worktrees are not included in that run; fresh validation follows integration.
After auth and HTTP integration, a fresh strict-unhandled-rejection full server
run completed naturally: **799 tests, 793 pass, 6 skip, 0 fail**, about 32.8 s.
Command: `node --unhandled-rejections=strict --import tsx --test
"packages/server/src/**/*.test.ts"`. Log: managed shell
`sh_0f87b4309001T4M1Bgo7XpAvfz.out`. This includes the new login-authority race
regression; counts are not additive with earlier runs. Final review and frozen
composition validation remain outstanding.
The complete server build also passes (`npm run build --workspace
@neuralnomads/codenomad`), including production UI, TypeScript, auth assets and
bundled pruning/automation plugins. UI and Electron typechecks pass afterward;
diff check reports no whitespace defects. Vite reports chunks above 500 kB; no
unrelated bundling policy change or warning suppression was introduced. This
build did not start or deploy a desktop/backend process.
The first complete browser run finished naturally: **349 tests, 348 pass, 1 skip,
0 fail**, about 22.2 minutes (`npm run test:browser --workspace @codenomad/ui`,
log `sh_0f86cb7bb001g0xkd6q5qnv4G2.out`). It includes the five compaction cases,
task-shell retention, renderer/history/navigation and native Electron fixture
checks. The opt-in Electron tab-chrome check is skipped. Because this run began
while source owners were still integrating, it is not a frozen-source final
qualification; its event-isolation file executed before the additional deferred
Solid error case, and the later browser HTTP fixture was not included. Do not
reuse or add its count to the forthcoming fresh composition gate.
The independent server integration review accepts auth and HTTP changes without
a blocking finding; see `issue-824-server-review.md`. It preserves the distinction
between proven local defects and the unqualified reporter scenario. UI review
accepts compaction and SSE diagnostics; its task-membership finding was corrected
and closed by independent re-review. Dedicated HTTP browser checks are complete.
Fresh combined validation subsequently finished with before/after source fingerprints
covering tracked and untracked executable inputs. Its acceptance verdict and later
bounded follow-ups are below; documentation updates are excluded from its source
manifest.
## Reviewed-composition validation: acceptance remains blocked
The fresh validation uses 1,696 tracked and untracked executable inputs. Completed
gates have identical before/after manifests, SHA256
`16a3d4085dac90c27e892a480ae4c0735eb52782a9add8b0ca6732a640f1a4df`.
This verifies their boundaries, not a filesystem lock or installed dependencies.
Exact commands, logs and limitations are in `issue-824-combined-validation.md`;
private artifacts remain under
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-combined-b91c8b66fcc840e1909170c5eeb846b9/`.
- Full server replay: **799 tests, 793 pass, 6 skip, 0 fail**, natural exit 0.
The first run's private Git-directory cleanup `EPERM` (792 pass, 1 fail, 6 skip)
is retained; the isolated nine Git tests and full replay pass with unchanged
sources. Its Windows cause is not established.
- UI/server/Electron typechecks and server build (including UI and bundled
plugins) pass naturally. A fresh complete private native fixture passes on
OpenCode 2.0.21; no user runtime or storage was used.
- Stores: **141/141 assertions pass with force-exit**. The natural-exit attempt
for the new compactage tests prints 11 passes but times out after 30 seconds;
lifecycle cleanup is not qualified. Four event-handler tests exit naturally.
- Full browser: **360 tests, 356 pass, 3 fail, 1 skip**, natural exit 1 in about
21.6 minutes. Failures are focus not returning to the composer after successful
Git diff insertion (line 609), MCP fixture readiness at reduced motion/150%
scale, and the expected legacy cached HTML not appearing after the literal-mode
toggle (line 104; earlier HTML/source checks passed).
All three pass in a targeted natural-exit replay without source edits; that
does **not** convert the full suite into a passing gate or establish the causes.
The existing focused replay has also finished naturally: **104/104 pass**, no
skip/cancelled, unchanged sources. Its eight suites include all mission browser
gates and the full session-rendering file. The validator's terminal report is
received; validation execution is complete, acceptance is still non-green.
- Fresh renderer A/B on the final membership-corrected postimage: three couples,
30 samples per variant, instrumentation off, natural exit 0. Median main-thread
**324.57 → 16.94 ms** (baseline range 168.06–743.53 ms, fixed 9.93–50.33 ms).
Raw metrics: `C:/Users/Admin/AppData/Local/Temp/opencode/codenomad-render-cost-srSQOf/metrics.json`.
This supersedes the earlier postimage's CPU qualification, not its historical
evidence; it measures the bounded fixture, not desktop FPS or all task costs.
No functional review blocker remains. However, the fresh full-browser gate is
non-green and the reporter's Electron Linux → Windows LAN/HTTP/OpenCode 2.0.19
scenario remains unqualified. The original freeze's exact cause is not established;
the private 2/8/32 MiB native compaction and loopback HTTP fixtures did not reproduce
a persistent global freeze. Do not mark #824 universally resolved or publish/install
these changes from this evidence alone.
The independent `browser-focus-cache-diagnosis` is now complete without editing
sources. It identifies a test synchronization gap: insertion updates the value
immediately, but composer focus/selection run in `setTimeout(0)`; the test asserts
focus immediately after reading the value. The original failed instant is allowed
by that contract; persistent loss of focus or draft is not demonstrated. Three
new complete focus passes and three HTML-cache passes are recorded separately.
A fourth focus attempt fails earlier, during a detached/intercepted Monaco overlay
click, and is retained as a distinct failure rather than attributed to #824.
The HTML cache transition is a synchronous local cache hit, so ordinary slow
Markdown highlighting is not its explanation; the original timeout remains
unattributed. See `issue-824-browser-focus-cache-diagnosis.md`.
The bounded `browser-mcp-bootstrap-diagnosis` is also complete: readiness timed
out in one of five natural-exit runs, with unchanged executable sources. The
network observations saturated the private harness's shared 4,000-entry budget
before terminal stages/errors/snapshots, so attribution is still impossible.
This is a confirmed diagnostic-capture weakness, not an identified product cause.
See `issue-824-browser-mcp-bootstrap-diagnosis.md`; all five outcomes are retained.
`browser-focus-test-sync` has now completed. The coordinator reviewed the diff:
after the unchanged text assertions, the test captures the textarea and waits for
its identity to equal `document.activeElement`, then retains the original identity
assertion. No sleep, new timeout, force-click, retry or product edit. Focus passes
2/2; the two-case run passes once and fails once on the independent HTML-cache
timeout. That timeout is now reproduced, not merely an unknown historical failure.
Opt-in `CODENOMAD_BROWSER_BOUNDARY_TRACE=1` prepares cache/overlay/focus diagnostics;
it was added after those two runs and has not yet captured the cache recurrence.
See `issue-824-browser-focus-test-sync.md`. Three authorized test/fixture inputs
changed; the earlier combined manifests remain historical, not fingerprints of
the revised test composition. Four selected product inputs remain unchanged in
the specialist's focused manifest; that subset is not a full-product manifest.
`browser-mcp-critical-capture` is now terminal: the private collector's overflow
is verified without a browser, then **5/5 targeted runs pass naturally** in 54.35 s
with complete critical traces, zero overflow and zero executable-source drift.
The 1,696-input comparison confirms that only the three authorized focus-sync
test/fixture paths differ from the earlier combined validation. Settings and render
finish in tens of milliseconds in these passes; most observed navigation time is
before the fixture body. That localizes cost in passes, not the historical timeout.
No sixth targeted run or product patch is justified. The prior red MCP and recurrent
HTML-cache timeout remain open; see `issue-824-browser-mcp-critical-capture.md`.
`browser-final-boundary-gate` has finished its **single normative full-browser
run**: **360 tests, 358 pass, 1 fail, 1 skip, 0 cancelled**, natural exit 1 in
20 min 10 s. All 1,696 inputs remain identical before/after, new manifest SHA256
`da74db30b68829edd40b44c069191cba8b21cc3e5e5d345862eec0cb9a14eb1c`.
The only failure is the final `errors=[]` assertion in the Git insertion test:
`ReferenceError: __name is not defined` from the anonymous opt-in browser script.
All earlier functional assertions in that case, including focus, pass in this
execution. The trace array is present but empty; it cannot prove absent gestures.
MCP and HTML-cache cases pass here, without attributing their earlier failures.
No retry or another full suite was run. See `issue-824-browser-final-boundary-gate.md`.
Acceptance remains **non-green**. `browser-trace-serialization-fix` is now
complete: the exact callback imported through the installed `node --import tsx`
serializes `const describe=__name(...)` without the module-scope helper. A minimal
Chromium page reproduces the same exception and empty trace. This is a confirmed
test-instrumentation defect introduced during this mission, not a product defect.
It is corrected with explicit, self-contained JavaScript passed to
`addInitScript({ content })`. The minimal Chromium check passes with no pageerror,
all observation kinds present and 200 entries retained after saturation. Snapshot
failure is isolated from the test assertion and page closure. UI typecheck and
diff-check pass; assertions, gestures, timeouts and product remain unchanged.
See `issue-824-browser-trace-serialization-fix.md`. The recorded full gate stays
red; there has been **no full-suite or feature-case rerun** after this correction.
Coordinator checkpoint, 2026-10-02 00:03 CEST: all **26 assigned tasks are
terminal completed**, no outstanding task, and no matching browser runner was
observed in the read-only Windows process check. A fresh hash comparison of all
**1,696 executable inputs**, including current tracked/untracked paths, against
the final gate finds exactly one difference: `git-history.test.ts`, SHA256
`dc7c378cc15e54f22f5d7e1a4c485313f0af9df536e1544690a33e3b2b2e5264`.
No product, configuration or script drift is found. This checkpoint is not a new
test execution or retroactive all-green verdict.
Next decision, not work running in the background: an acceptance run on the
corrected test composition is still needed for an all-green full-browser claim.
No automatic retry, extra audit or deployment is launched at this checkpoint.
The exact original #824 cause, earlier MCP/cache failures and natural store
cleanup remain unqualified; the mission is not finished as a successful fix.
Cross-host traces remain to be collected
according to `issue-824-browser-http.md`. No new product patch is justified solely
by a passing retry. No commit/push/merge/install/restart/session cleanup is
authorized by this checkpoint.
## Draft PR handoff authorized by the user
2026-10-02: the user requested a PR containing all mission changes. Deliver the
63 reviewed product/test/script/report paths on `fix/issue-824-performance` to
`dev` as a **draft**, without a closing keyword for #824. Preserve every recorded
failed run and qualification limit. The draft is the reviewable deliverable, not
a claim that all gates pass or the reported freeze is universally resolved.
All 26 investigation/correction/review tasks are complete and no test runner is
active at the handoff checkpoint. The durable mission's nonterminal status does
not mean that an agent or command is still executing: acceptance is incomplete.
No automatic retry or extra general audit accompanies publication. Acceptance
still needs the corrected test composition qualified and the cross-host limits
resolved or explicitly accepted; merge/install/restart/session cleanup remain
outside this publication authorization.
Modified source files above the size guideline (no size-only refactor):
`packages/server/src/server/http-server.ts` ~2,346 lines,
`packages/server/src/workspaces/manager.ts` ~1,240,
`packages/ui/src/stores/instances.ts` ~2,093,
`packages/ui/src/stores/opencode-data.ts` ~828,
`packages/ui/src/components/tool-call/renderers/task.tsx` ~569.

View file

@ -0,0 +1,189 @@
# Issue #824 — continuité de l'audit performance
- Date : 2026-10-01
- Baseline examinée : `366830f6` (`origin/dev`)
- Audit repris : worktree `audit-resume-integration`, baseline `47f9e43e`
## Portée et niveaux de preuve
Diagnostic en lecture seule du produit. Aucun daemon, profil, desktop ou stockage
utilisateur n'a été démarré ou modifié. Le seul exercice exécuté ici est un micro-
benchmark local de la primitive d'authentification, sans réseau ni fichier partagé.
Les affirmations ci-dessous portent explicitement l'un des niveaux suivants :
- **Mesure exécutée** : observation chronométrée pendant cette mission ou mesure A/B
conservée par l'audit précédent avec protocole et artefact décrits.
- **Reproduction fonctionnelle antérieure** : assertion rouge/verte exécutée par
l'audit précédent, sans nouvelle mesure de latence dans cette mission.
- **Analyse statique** : chemin de code observé ; coût ou impact encore à mesurer.
- **Hypothèse** : mécanisme plausible à tester, pas un défaut confirmé.
Les diagnostics spécialisés de compactage et de transport HTTP Linux/Windows sont
hors de cette carte. Les priorités proposées sont transversales et complémentaires.
## Continuité entre l'audit et la baseline courante
L'audit précédent n'était pas un commit : `audit/resume-integration` est toujours à
`47f9e43e` avec ses corrections en fichiers modifiés/non suivis. Entre `47f9e43e` et
`366830f6`, les 16 commits de `origin/dev` concernent notamment Files, responsive,
usage, OpenCode 2.0.20/2.0.21 et Tauri macOS. Ils ne constituent pas l'intégration
générale de l'audit.
Contrôle objet Git effectué : les dix fichiers sentinelles suivants ont exactement
le même blob à `47f9e43e` et à `366830f6` :
| Surface auditée | Blob encore présent à `366830f6` | État |
| --- | --- | --- |
| rendu des tâches imbriquées, `packages/ui/src/components/tool-call/renderers/task.tsx` | `dee90df8560b83958dbf6d6f7c40e013bbdb289f` | correction audit absente |
| distribution SSE UI, `packages/ui/src/lib/server-events.ts` | `29d8342071fee3fc1ab3a2f7bc71c847bc68f8a8` | correction audit absente |
| itérateurs/relay OpenCode, `packages/server/src/workspaces/opencode-service.ts` | `ad8cfbaa00ebce3cc47e9a70217244217cb8b3dd` | correction audit absente |
| Speech/Sidecars, `packages/ui/src/stores/speech.ts`, `sidecars.ts` | `45832e4c…`, `d52a8d5f…` | corrections audit absentes |
| fermeture multi-fenêtres Electron, `packages/electron-app/electron/main/multiwindow-lifecycle.ts` | `59995dfd1b36cc4d4eee742c4798477cb5b59b9b` | correction audit absente |
| historique pruning, `packages/server/src/opencode/session-pruning/history-database.ts` | `37318db55235fb9b50ffd2efb60a154495f6967a` | correction audit absente |
| polling provider, `packages/ui/src/components/provider-auth/provider-manager-modal.tsx` | `c1e977a70acaa649ab81d3dba5d6019c693f4f09` | correction audit absente |
| lecture conversation, `packages/ui/src/stores/conversation-speech.ts` | `67a12d80c070874f222b24b7b44dfd400beb51a6` | correction audit absente |
| auth manager, `packages/server/src/auth/manager.ts` | `4e234ace6fc1b94ebb8543617d7d8048239135db` | correction audit absente |
`packages/server/src/server/http-server.ts` a changé dans #811/#814, mais la
comparaison avec le worktree d'audit montre encore un delta : ces commits ne sont
pas l'intégration de la composition preview/redaction auditée.
### Ce que l'audit avait réellement qualifié
- **Rendu imbriqué** : mesure A/B historique sur 80 étapes `read` de 8 000 caractères,
30 échantillons par variante. Médiane main-thread `369,98 -> 20,64 ms` par delta,
remounts DOM `80 retirés + 80 insérés -> 0`; 80 snapshots natifs restent. Protocole,
variance et artefact dans `AUDIT_RENDERING_COST.md:40-80`. Deux assertions rouges
sur la source baseline et 63 tests navigateur verts sur la correction
(`AUDIT_RENDERING_COST.md:128-145`). **Mesure historique, pas rejouée sur 366830f6.**
- **Isolation événements** : 50 tests SDK/HTTP/relay/server et 4 régressions Chromium
verts sur la correction ; un destinataire relay lent n'y bloque pas le destinataire
rapide (`AUDIT_UPSTREAM_DEV_2026_09_30.md:193-208`). La correction isole exceptions
et fins locales d'itérateur, pas un callback UI qui consomme le CPU.
**Reproduction fonctionnelle antérieure, pas mesure de latence.**
- Les correctifs Speech/Sidecars, provider polling, pruning/history et fermeture
concurrente ont eux aussi été qualifiés dans le worktree d'audit, mais aucun signal
ne permet de les compter comme intégré à `366830f6`. Ils restent des correctifs de
robustesse/lifecycle, pas les premières reproductions performance de #824.
- L'audit a réfuté dans son fixture réel le montage de tout le transcript/timeline et
le DOM non borné des gros outputs (`AUDIT_RENDERING_COST.md:83-100`). Ne pas rouvrir
ces trois pistes sans nouvelle preuve contraire.
## Priorités résiduelles bornées
### P1 — Rejouer puis intégrer la correction de rendu des tâches imbriquées
**Niveau : mesure historique confirmée ; correction absente de la baseline.**
Le code courant recrée le rendu complet via un `createMemo` dans
`task.tsx:54-99`, et vide encore les clés à `task.tsx:236-243` pendant une
réindexation. Chaque `ToolCall` clone aussi profondément son entrée à la création et
à chaque changement de versions (`packages/ui/src/components/tool-call.tsx:630-640`).
Cela correspond exactement au chemin mesuré par l'audit. Le coût résiduel des 80
`structuredClone` est lui-même mesuré, mais son optimisation n'est pas validée.
**Plus petite prochaine reproduction :** récupérer sans l'élargir le fixture
`scripts/test-render-cost-audit.mjs` et `packages/ui/tests/browser/render-cost.test.ts`
du worktree audit, exécuter d'abord l'override rouge sur le blob
`dee90df8560b…`, puis A/B sur `366830f6` et sur la correction auditée. Conserver les
assertions d'identité DOM comme gate ; ne pas introduire de seuil timing CI instable.
### P2 — Isoler les abonnés SSE et mesurer le temps de distribution
**Niveau : défaut fonctionnel reproduit antérieurement ; risque CPU statique.**
La baseline distribue ouverture, événements et statuts avec des `Set.forEach`
synchrones sans frontière par callback (`packages/ui/src/lib/server-events.ts:69-76`,
`:151-170`). L'audit a confirmé qu'une exception d'un abonné supprime les abonnés
suivants et peut interrompre un restart. Cela peut ressembler à une application figée
alors que le transport reste vivant. En revanche, aucune preuve ne montre encore un
callback lent bloquant durablement le renderer ; l'audit précise que ce cas n'est pas
isolé.
**Plus petite prochaine reproduction :** réimporter le fixture
`event-subscriber-isolation.test.ts`, prouver rouge/vert sur l'exception, puis ajouter
un lot borné d'événements avec un abonné instrumenté volontairement lent. Mesurer
durée de `dispatchBatch`, retard d'un `requestAnimationFrame` sentinelle et abonnés
appelés. Le premier correctif utile reste la petite barrière `try/catch` déjà auditée ;
une planification asynchrone ne doit être envisagée qu'après mesure car elle change
l'ordre/la sémantique Solid `batch`.
### P3 — Retirer `scryptSync` du thread HTTP d'authentification
**Niveau : mesure exécutée et analyse du chemin HTTP.**
`verifyPassword` et `hashPassword` utilisent `crypto.scryptSync`
(`packages/server/src/auth/password-hash.ts:23-28`, `:37-48`). Le POST login appelle
ce chemin synchroniquement via `AuthManager.validateLogin`
(`packages/server/src/auth/manager.ts:74-79`; route dans
`packages/server/src/server/routes/auth.ts:99+`). Pendant ce calcul, le thread Node
partagé ne sert ni autres requêtes ni événements des autres sessions.
Micro-benchmark exécuté sur Windows, Node `v25.2.1`, paramètres de production
`N=16384,r=8,p=1`, 10 vérifications séquentielles : médiane **19,56 ms**, plage
**19,12–21,60 ms**. C'est une durée de blocage de primitive, pas une mesure Fastify,
desktop ou charge concurrente. Une rafale de tentatives multiplie directement ce
blocage ; l'impact réel et les limites de taux ne sont pas qualifiés ici.
**Plus petite prochaine reproduction :** fixture Fastify inject/loopback isolée avec
un heartbeat de latence pendant une rafale bornée de logins invalides. Mesurer p50/p95
du heartbeat et durée login avant/après passage à `crypto.scrypt` asynchrone, en
conservant comparaison constante, paramètres et réponses d'auth identiques. Couvrir
aussi `setPassword`, qui appelle `hashPassword` sur le même thread.
### P4 — Borner le coût renderer + disque de la persistance d'état client
**Niveau : analyse statique ; hypothèse de freeze à reproduire.**
Le renderer recapture l'état après 100 ms d'activité
(`packages/ui/src/lib/hooks/use-app-session-capture.ts:124-168`), puis la couche état
reprogramme une sauvegarde après 250 ms (`packages/ui/src/stores/client-state.ts:10`,
`:85-128`, `:139-145`). Chaque sauvegarde normalise, encode tout le graphe et contrôle
jusqu'à 4 096 partitions / 256 MiB autorisés
(`client-state-partitions.ts:8-15`, `:188-220`). Côté Electron, chaque mutation clone
l'enveloppe avec `JSON.parse(JSON.stringify(...))`, la resérialise, l'écrit, rename et
fsync le répertoire (`packages/electron-app/electron/main/client-state.ts:628-692`).
Le chemin normal est asynchrone côté disque ; les migrations/cross-host utilisent
encore des opérations sync, mais surtout au démarrage/récupération, pas à chaque save.
Aucun profil de gros état ou disque lent n'a été exécuté : **ne pas décrire cette
piste comme cause confirmée**.
**Plus petite prochaine reproduction :** fixture Electron/module isolée générant des
états réalistes à 1, 16, 64 et 256 MiB, avec churn de draft/layout à 10 Hz. Instrumenter
séparément `encodeClientSnapshotV2`, IPC, file d'écriture, retard event-loop renderer
et main, taille/compte de partitions et durée de flush. Simuler un writer lent injecté,
sans disque utilisateur. Le résultat dira s'il faut du partage structurel, un worker,
une fréquence moindre ou uniquement une borne plus basse.
### P5 — Mesurer la rafale périodique de réconciliation multi-instance
**Niveau : analyse statique ; hypothèse de contention réseau/réactive.**
Toutes les 30 secondes, `App.tsx:276-289` parcourt simultanément toutes les instances
`ready`. Même sans session active, `runPendingRequestLiveness` finit par
`syncPendingRequests` (`packages/ui/src/stores/instances.ts:864-887`), lequel lance en
parallèle scans permissions et Forms, avec jusqu'à trois reprises de stabilisation
(`instances.ts:795-817`). Les appels sont coalescés par instance, mais pas étalés entre
instances. Des horloges UI à 1 s existent aussi dans `session-list.tsx:117-121`,
`instance-shell2.tsx:375-379` et `instance-tab.tsx:28`; leur coût n'est pas mesuré.
**Plus petite prochaine reproduction :** monter 1/5/20 instances contre clients fake,
compter requêtes et concurrence au tick 30 s, puis mesurer retard RAF et commits Solid
avec zéro puis plusieurs Forms/permissions. Tester visibilité/inactivité séparément.
Ne modifier intervalle, staggering ou demande visible qu'après avoir établi une pente
avec le nombre d'instances.
## Ordre recommandé
1. Rejouer P1 : gain historique majeur et patch déjà borné/qualifié.
2. Rejouer P2 : évite les faux freezes d'état sans toucher au transport.
3. Corriger P3 si le test Fastify confirme le retard concurrent ; le mécanisme bloquant
est déjà mesuré.
4. Profiler P4 et P5 en parallèle conceptuel, mais ne promouvoir un correctif qu'avec
une mesure par phase et un fixture de non-régression.
Cette carte ne prétend pas expliquer le freeze de compactage ni le blocage HTTP
Linux/Windows persistant après restart. Elle évite également de compter comme
intégrées les corrections seulement présentes dans `audit/resume-integration`.

View file

@ -0,0 +1,193 @@
# #824 — coopération du serveur pendant les calculs de mot de passe
## Périmètre et résultat
Tâche initiale `auth-loop-responsiveness`, branche `fix/issue-824-auth`, worktree
`D:\CodeNomad\.codenomad\worktrees\issue-824-auth`, baseline `366830f6`.
Intégration `integrate-auth` dans
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance` : sept fichiers
autorisés seulement, via patch tool, sans réinstallation des dépendances.
Défaut confirmé puis corrigé : les routes de login et de changement de mot de
passe exécutent une dérivation scrypt coûteuse sur le thread HTTP partagé.
Les dérivations HTTP utilisent maintenant `crypto.scrypt`, sur le pool libuv,
et leurs callers attendent le résultat. Aucun changement logout, compaction,
transport SSE, daemon OpenCode ou configuration utilisateur par ces tâches.
## Reproduction comportementale rouge
`packages/server/src/server/routes/auth-responsiveness.test.ts` utilise le vrai
Fastify, les vraies routes et un vrai AuthManager. Chaque fixture crée un
`auth.json` v1 privé sous `%TEMP%/opencode/codenomad-auth-responsive-*`, avec un
hachage produit indépendamment par l'ancienne API synchrone. Elle ferme Fastify
et supprime uniquement son propre répertoire à la fin.
Deux rafales bornées : 8 logins invalides, puis 4 changements authentifiés de
mot de passe. À l'entrée du premier calcul, un `setImmediate` programme un GET
sur un endpoint témoin, qui observe le nombre d'opérations encore en cours.
L'assertion exige que le témoin s'exécute **avant la fin de tous les calculs**.
Les durées ne sont que des diagnostics, sans assertion de seuil en ms.
Le corps des méthodes, le calcul cryptographique et les handlers ne sont pas
remplacés ; les wrappers ne font que compter et chronométrer.
Avant modification produit, les deux tests ont échoué : témoin après les
8/8 logins (~169 ms), et après les 4/4 changements (~84 ms). Réponses correctes
mais aucun travail témoin pendant la rafale. Chaque appel bloquait ~21 ms.
Après modification, l'ancien code a été recopié par `git show 366830f6:<path>`
dans une fixture privée sous le `node_modules/.cache` du worktree **source**,
avec les tests courants. Les deux mêmes assertions échouent dans chacune des
5 exécutions rouges. Aucun checkout ni fichier produit n'a été inversé pour
ce contrôle.
Fixture baseline retenue localement (non versionnée, dans `issue-824-auth`,
non recopiée dans le worktree mission) :
`node_modules/.cache/auth-baseline-4193325c8f9a4bcd973718259e94c2f6`.
Elle contient `auth/{auth-store,manager,password-hash,http-auth,token-manager,session-manager}.ts`,
`server/routes/auth.ts` de la baseline et le test de réactivité courant ; les
imports de dépendances utilisent seulement le node_modules du worktree source.
## Mesures exécutées rouge / vert (tâche initiale)
Windows x64, Node `v25.2.1`, pool libuv par défaut (4), mêmes paramètres scrypt.
Cinq processus Node distincts par variante, tests témoins seuls ; pas de seuil
de performance ni de conclusion statistique au-delà de ces échantillons.
| Mesure (médiane des 5 runs) | Baseline | Correctif initial |
| --- | ---: | ---: |
| Témoin pendant les 8 logins | 168,63 ms | 3,37 ms |
| Durée totale des 8 logins | 179,23 ms | 67,33 ms |
| Témoin pendant les 4 changements | 82,43 ms | 0,80 ms |
| Durée totale des 4 changements | 84,23 ms | 93,57 ms |
| État au témoin : logins encore en cours | 0/8 | 8/8 |
| État au témoin : changements encore en cours | 0/4 | 4/4 |
Échantillons témoin, en ms, dans l'ordre des runs :
- Rouge login : 187,0279 ; 163,5008 ; 161,1307 ; 168,6303 ; 173,3027.
- Vert login : 4,6347 ; 3,3659 ; 2,9695 ; 3,1687 ; 3,7206.
- Rouge password : 91,9619 ; 82,9719 ; 82,2616 ; 82,4302 ; 82,1526.
- Vert password : 1,1573 ; 0,6115 ; 0,6394 ; 0,7956 ; 1,6040.
La sérialisation des changements vise la cohérence, pas leur débit. Leur temps
total n'est pas amélioré ; le témoin et donc la coopération du thread HTTP le
sont. Le coût CPU scrypt n'est pas supprimé.
## Correction et garanties
- Format v1, sel aléatoire 16 octets, clé 64 octets, paramètres par défaut
`N=16384, r=8, p=1, maxmem=32 MiB` inchangés.
- Vérification avec les paramètres enregistrés et comparaison
`crypto.timingSafeEqual` conservées ; longueur incompatible et algorithme
inconnu restent des refus. Les erreurs crypto deviennent des rejets attendus.
- Login attend le booléen, sinon une Promise truthy aurait autorisé les mauvais
mots de passe. Password attend le calcul **et la persistence** avant succès,
et attrape les rejets pour conserver la réponse 409 text/plain.
- AuthStore sérialise uniquement les changements de mot de passe par ordre
d'admission. Un échec ne bloque pas les suivants ; le bootstrap ne perd pas
son identité entre deux calculs ; le dernier changement admis gagne.
- Lors de l'intégration, une fence d'identité du record d'autorité a été ajoutée
après la dérivation : `valid && this.load() === auth`. Un changement réussi
remplace le record cached ; un échec ne le remplace pas. Une dérivation sur
un ancien record ne peut plus accorder de session après son remplacement.
Aucun nouveau calcul ni replay automatique du login.
- Désactivation d'auth, override CLI/env et cookies conservent leur sémantique.
- Le hachage synchrone de l'override CLI/env reste explicitement nommé
`hashPasswordSync`, appelé seulement par `ensureInitialized` au constructeur,
avant le service HTTP. Cela évite d'élargir cette correction au cycle de
démarrage. Les lectures/petites écritures auth.json synchrones ne changent pas.
- Recherche des callers effectuée dans le dépôt : les deux handlers HTTP sont
les seuls callers produit de validateLogin/setPassword ; les await ont été
ajoutés. Le typecheck serveur valide cette propagation.
- Aucun rate limiter explicite n'a été trouvé dans les routes/auth courantes :
aucun mécanisme existant n'est retiré, et aucun nouveau n'est introduit ici.
## Validation et commandes
```powershell
node --import tsx --test packages/server/src/auth/password-hash.test.ts packages/server/src/server/routes/auth-responsiveness.test.ts packages/server/src/server/routes/auth.test.ts
npm run typecheck --workspace @neuralnomads/codenomad
git diff --check
```
Tâche initiale : 8 tests verts, répétés cinq fois (40/40 exécutions de tests),
puis 5 runs verts supplémentaires des seuls tests témoins. Typecheck serveur
vert. Diff check sans erreur (avertissements Git habituels LF/CRLF seulement).
Intégration : les sept chemins cibles étaient sans modifications au départ.
Les quatre fichiers produit et les deux tests ont été repris via patch tool ;
seuls `auth-store.ts` et le test de réactivité ont été enrichis pour la course
de changement de mot de passe. Le rapport source est repris et actualisé ici.
Les changements voisins de mission ne sont pas touchés.
### Course login / changement de mot de passe : rouge puis vert déterministe
Le test `rejects a pending login verified against a password replaced before
verification returns` fait réellement dériver l'ancien mot de passe par
`crypto.scrypt`, mais retient son callback. Il enregistre ensuite un nouveau
mot de passe par la vraie route `/api/auth/password` et vérifie le nouveau
secret, avant de libérer l'ancien callback. Il ne suppose aucune durée ni
ordre implicite du scheduler. Le mock est restauré et les requêtes privées
sont drainées avant fermeture.
Sur le correctif initial intégré sans fence : échec attendu, **200 au lieu de
401**, après le succès de la mutation password. Après ajout de la fence :
**401 Invalid credentials sans Set-Cookie**, nouveau secret valide, ancien
secret refusé. L'authentification ne réutilise pas une autorité remplacée.
La suite intégrée passe **9/9 tests**, dont les 8 tests précédents et ce nouveau
cas ; elle a été rejouée trois fois supplémentaires (27/27 verts).
Typecheck serveur vert. Exemple de diagnostics au run intégré : témoin
login 3,29 ms avec 8 opérations encore en cours ; password 0,52 ms avec 4 en
cours. Ces mesures sont descriptives et séparées du tableau A/B initial.
```powershell
node --import tsx --test --test-name-pattern='rejects a pending login' packages/server/src/server/routes/auth-responsiveness.test.ts
```
Commande témoin courte, utilisable sur le correctif ou avec le chemin du test
dans la fixture baseline source ci-dessus :
```powershell
node --import tsx --test --test-name-pattern='services a witness' packages/server/src/server/routes/auth-responsiveness.test.ts
```
Couverture : ancien hachage v1, paramètres personnalisés, nouveau hachage lu
par scryptSync indépendant, mot de passe et utilisateur invalides, login valide
et cookie, refus de password non authentifié, erreurs override/désactivation,
bootstrap concurrent, ordre des changements, échec persistence puis reprise,
réactivité login/password, autorité remplacée pendant une vérification et
compatibilité du test login HTML existant.
Installation isolée initiale des dépendances par `npm ci --ignore-scripts`
(lockfile inchangé). npm signale 49 vulnérabilités préexistantes ; pas de
`npm audit fix` ni de mise à jour hors périmètre. Aucune réinstallation lors
de l'intégration ; dépendances de mission existantes uniquement.
## Limites et suite minimale
- Les requêtes passent par `Fastify.inject`/light-my-request, pas par un socket
réseau ni Chromium. Ceci isole le défaut du thread HTTP, sans prétendre
reproduire la saturation HTTP Linux→Windows, SSE ou compaction.
- Scrypt utilise toujours du CPU/de la mémoire et partage le pool libuv avec
d'autres opérations. La saturation du pool et les rafales non bornées ne sont
pas qualifiées ici ; une politique d'admission serait un travail séparé.
- La fence compare l'autorité cached/override de ce processus, comme le store
existant. Elle ne crée pas de surveillance des changements externes du
fichier auth.json ni de révocation des sessions déjà accordées.
- Aucun serveur utilisateur, daemon partagé, profil, desktop ou base native
OpenCode utilisé ; aucun restart, déploiement, commit ou push.
- Intégration effectuée ; revue indépendante et validation finale de mission
restent nécessaires. Aucune correction logout ou politique rate-limit.
## Fichiers du patch
1. `packages/server/src/auth/password-hash.ts`
2. `packages/server/src/auth/auth-store.ts`
3. `packages/server/src/auth/manager.ts`
4. `packages/server/src/server/routes/auth.ts`
5. `packages/server/src/auth/password-hash.test.ts` (nouveau)
6. `packages/server/src/server/routes/auth-responsiveness.test.ts` (nouveau)
7. `dev-docs/issue-824-auth-responsiveness.md` (ce rapport)
Tous les fichiers source/test modifiés restent sous les seuils AGENTS.

View file

@ -0,0 +1,198 @@
# #824 — gate navigateur final unique
## Verdict terminal
**Gate non vert : 360 tests, 358 pass, 1 fail, 1 skip, 0 cancelled.**
Commande normative exécutée **une seule fois**, exit 1 **naturel**, sans timeout ni
force-exit. Zéro dérive des sources. Travail de validation terminé et rapporté,
pas acceptation globale. Aucun retry/ciblé ou second fullbrowser après ce rouge.
L'échec unique est `git-history.test.ts:684`, sur le contrôle final `errors=[]` :
un pageerror `ReferenceError: __name is not defined` vient d'un script navigateur
anonyme. Ce n'est **pas** l'ancien échec d'identité du focus ni le clic Monaco.
Le snapshot opt-in est présent mais sa chronologie est vide. Le cache HTML et le
cas MCP précédemment rouges passent ici, sans démontrer leur résolution causale.
Le gate historique 356/3/1 et les diagnostics ciblés restent des pièces séparées.
## Admission fraîche
Workdir exact : `D:/CodeNomad/.codenomad/worktrees/issue-824-performance`.
HEAD `366830f6b4373e6b283cabf7dd04764e87e1cc32`, changements mission non committés.
Rapports `issue-824-browser-focus-test-sync.md` et
`issue-824-browser-mcp-critical-capture.md` lus, leurs tâches terminées.
Contrôle processus Node read-only préalable : aucun runner navigateur concurrent
repéré ; résultat et heure conservés dans `process-preflight.json` et
`preflight-time.txt`.
Nouveau manifeste **1 696 fichiers**, tracked + untracked non ignorés : sources
produit, tests, scripts, configs et locks ; exclusions docs/Markdown/PDF,
dist/public/deps/cache/release/target. Comparaison avec `combined/.../initial.json` :
**exactement les trois inputs autorisés de focus-sync** ont changé :
- `packages/ui/tests/browser/git-history.test.ts` ;
- `packages/ui/tests/browser/session-rendering.test.ts` ;
- `packages/ui/tests/browser/fixtures/user-html.tsx`.
Comparaison des hashes de fichiers avec `mcp-critical/.../terminal-manifest.json` :
**aucun écart**. Aucun autre changement à déclarer ou autoriser ; hashes produit
inchangés depuis la validation combinée. `admission.json` conserve tous les hashes
anciens/nouveaux, chemins de référence et décisions. `browser-before.json` est
identique à l'admission ; le runner aurait refusé une dérive avant lancement.
SHA256 nouveau `initial.json` :
`da74db30b68829edd40b44c069191cba8b21cc3e5e5d345862eec0cb9a14eb1c`.
SHA256 combined initial historique :
`16a3d4085dac90c27e892a480ae4c0735eb52782a9add8b0ca6732a640f1a4df`.
Le manifeste MCP map-only est un format différent mais les hashes de fichiers
correspondent exactement : SHA
`4628acfc4df16f8e573da48dab23df3614a7a8a55643466e5e6e8e4f6e80105b`.
## Exécution normative et confinement
```powershell
$env:CODENOMAD_BROWSER_BOUNDARY_TRACE = '1'
npm run test:browser --workspace @codenomad/ui
```
Le script npm inchangé exécute
`node --import tsx --test --test-concurrency=1 tests/browser/*.test.ts`.
Pas de filtre, de liste privée substituée, de nouveaux délais/assertions/gestes,
ni de policy/cleanup du cache Vite. La capture MCP privée n'est **pas** insérée
dans ce fullbrowser : ses cinq passes restent ciblées/historiques, distinctes du
test MCP normal exécuté ici. Le trace opt-in préparé active uniquement les surfaces
focus/cache existantes. Aucun patch produit/test/fixture/script/config/deps/lock.
Le runner privé invoque la commande npm exacte via `cmd.exe /d /s /c`, depuis le
workdir exact, et conserve stdout/stderr sans filtrage dans `browser.log`.
Budget 60 minutes, exécution background avec notification de fin, pas de polling
répétitif. `started.json` créé en mode exclusif interdit un second lancement.
Si timeout, seule l'arborescence PID de ce runner npm privé est arrêtée, jamais le
daemon partagé ; timeout/forced se distinguent d'une sortie naturelle.
Artefacts :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-browser-final-0e81ac45520348328ab5bc06252a2010/`.
Runner `validate.cjs` SHA256 :
`2d31356e2f67899fc7a1207e145feb411907ff6f4bdc6bffa2842f12619e599d`.
Shell background : `sh_0f95dae060013DD7dMlptG7IAp`.
Aucun install, commit/push/merge, restart/déploiement, suppression de sessions,
accès au daemon/base/profil utilisateur, sous-agent ou autre gate. Les seuls edits
du validateur pour cette tâche sont ce rapport et les artefacts TEMP privés.
## Limites invariantes
Serveur/build/typechecks/A-B antérieurs restent historiques ; pas de nouvelle
exécution nécessaire ici puisque produit/scripts/locks sont identiques. Ce gate
ne qualifie ni Linux Electron → Windows LAN/OpenCode 2.0.19, ni la sortie naturelle
des stores. Un éventuel vert ne prouve pas l'élimination universelle des freezes
ni la cause des rouges précédents. Une panne HTML/Monaco/MCP reste rouge sans retry
et sans narration d'innocuité supposée.
Les snapshots sont ceux des tests existants. Si leur évaluation échoue, le log
complet doit conserver l'exception et le rapport distinguer `observation-error`
de l'échec produit/assertion ; un snapshot absent n'est pas un état vide démontré.
La mission et sa livraison terminale restent la décision du coordinateur.
## Résultat exact et captures
- Début UTC `2026-10-01T21:27:41.967Z`, fin `2026-10-01T21:47:52.008Z`.
- Durée runner **1 210 016 ms**, soit **20 min 10 s** ; durée Node test
`1 209 656.4827 ms`. Notification finale reçue, pas statut déduit du dernier test.
- Exit 1, signal null, launchError null, timeout false, forcedExit false,
naturalExit true. Aucune action d'arrêt du runner nécessaire.
- Total 360 = 358 pass + 1 fail + 0 cancelled + 1 skip, 0 todo, 0 suites.
- Skip : `Electron BrowserWindow native zoom retains tab seams and matching
scrollbars`, opt-in `CODENOMAD_TEST_ELECTRON` absent. Ne pas confondre avec les
autres fixtures Electron du corpus qui passent ; pas de qualification Linux/LAN.
- Node v25.2.1 Windows x64 ; Chromium par défaut Playwright, version 153.0.8010.12
confirmée par les logs HTTP. `NODE_OPTIONS=--enable-source-maps` hérité inchangé.
### Unique échec — assertion et limite observationnelle distinctes
Cas : `central diff inserts local lines and revision-qualified history into the
real session composer`, durée cas `3316.8763 ms`, déclaration ligne 590.
Assertion rouge ligne **684** : expected `[]`, actual :
```text
ReferenceError: __name is not defined
at <anonymous>:2:78
at <anonymous>:2:1004
at <anonymous>:3:7
```
Le cas a atteint le contrôle final après ses assertions de brouillon, références
Git, restitution de focus (`:630–632`), sélection de plage, historique de commit,
référence du worktree WSL, autorité de session et restauration de draft. Toutes
ces assertions ont donc passé **dans cette exécution**, malgré son statut final
rouge. Pas de timeout `activeElement`, de détachement/clic Monaco ou de replay.
Snapshot existant intégral, avant fermeture :
```text
Git focus boundary trace {
events: [],
active: { tag: 'BODY', id: '', className: 'enable-motion underline-links' },
widgets: [],
selections: []
}
```
**Observation-error distincte** : la collecte opt-in du script d'init n'a pas
produit de chronologie exploitable ; `__name` est un binding auxiliaire absent
dans le contexte navigateur. Le source de l'instrumentation (`:595–615`) initialise
le tableau puis définit un helper nommé `describe` et les listeners. Le mécanisme
attendu de transformation/sérialisation d'un helper par tsx/esbuild est cohérent
avec cette erreur, mais le JS exact sérialisé n'a pas été capturé : pas de preuve
supplémentaire par nouveau test ou expérience de transpilation dans cette tâche.
Le snapshot lui-même **n'a pas échoué à l'évaluation** : il est enregistré, sans
exception page.evaluate dans le résultat final. `events: []` ne démontre donc pas
l'absence de gestes/focus : la capture est défaillante. L'état BODY est celui de
la fin, après d'autres navigations ; il ne contredit pas l'assertion textarea passée
plus tôt. Les widgets absents en fin ne prouvent pas leur détachement pendant clic.
La régression observationnelle ne doit être ni attribuée au produit sur hypothèse,
ni ignorée pour déclarer ce gate vert.
### Frontières qui passent dans ce seul gate
- MCP `reduce, 150% scale` static rest : pass `1619.5347 ms` ; autres trois cas MCP
pass également. Test normal, aucune substitution/capture MCP privée insérée.
- HTML/cache : `user HTML mode preserves Markdown, pasted disclosures and code
source and fences legacy caches` pass `2196.9656 ms`. Aucune exception/snapshot
d'échec HTML imprimé, donc pas de nouvelle attribution du timeout ancien.
- Les 358 passes restent celles du corpus normal, pas additionnées aux
retries ni aux résultats historiques. Les erreurs de bootstrap antérieures
restent non attribuées ; aucun claim d'innocuité universelle.
## Empreintes et livraison terminale
`browser-before.json` et `browser-after.json` sont strictement identiques à
`initial.json`, SHA `da74db30b68829edd40b44c069191cba8b21cc3e5e5d345862eec0cb9a14eb1c`.
1 696 fichiers ; `drift: []`, `driftFromInitial: []`, HEAD inchangé.
Le runner exécuté conserve son SHA d'admission. L'immuabilité est constatée aux
bornes, pas un verrou filesystem pendant le run.
| Artefact privé | SHA256 |
| --- | --- |
| `browser.log` brut complet | `f24b7866e58109e029acdda5947caa24ce282f78818ff4ddd0a69b220f845eea` |
| `failures.log` extrait intégral du résumé rouge | `7db7e5c1ecfaf693f0540ea5d732df0c1f34435b55465012a5cf7bced8a3b5f9` |
| `boundary-output-through-end.log` trace puis suite jusqu'à fin | `fa7ef5a8b580e05cc810ac80af67fce3f18949c5d600e47e4981bdaad3b45b5f` |
`browser-result.json` conserve commande/statut/heures/durée/exits/manifests ;
`summary.json` vérifie compteurs et hashes ; `git-focus-snapshot.log` conserve le
snapshot exact, `boundary-observation-status.json` distingue l'observation-error
du contrôle `errors=[]` rouge et du snapshot évalué avec succès. Le postprocesseur
privé `summarize.cjs` ne lance aucun test/browser et ne filtre pas le log brut.
Serveur/build/typechecks/A-B ne sont pas rejoués ; leurs preuves historiques sur
produit identique restent qualifiées avec leurs limites déjà rapportées. Aucun
nouveau gate, correction, cleanup ou déploiement ajouté pour fabriquer du vert.
## Prochain minimum utile — décision coordinateur
Remettre ce **gate rouge unique** et les captures au propriétaire de focus-sync :
rendre le script opt-in navigateur autosuffisant à la sérialisation (par exemple
source JS explicite sans helper Node externe), sans modifier contrat produit,
assertions, délais ou gestes. C'est une recommandation bornée, **pas un patch ou
une relance autorisée ici**. Le coordinateur décide s'il faut un suivi séparé ou
s'il clôture avec cette limite ; aucune nouvelle boucle d'audit/retry du validateur.

View file

@ -0,0 +1,172 @@
# Issue 824 — attribution bornée des échecs navigateur focus et cache HTML
## Verdict
Diagnostic borné terminé, sans correction produit ni test.
- **Focus après insertion Git : course de synchronisation du test confirmée par
le contrat du produit; défaut produit #824 non démontré.** Le texte est inséré
immédiatement, mais la restitution du focus est volontairement programmée dans
une tâche `setTimeout(0)`. L'assertion du test ne synchronise pas cette tâche.
- **Cache HTML : non reproduit et non attribué.** Le chemin attendu après
`literal(false)` est un hit de cache local synchrone, pas une attente du renderer
Markdown. Les preuves disponibles ne permettent pas de choisir entre défaut de
réactivité ponctuel, cache Vite/optimisation du harness ou scheduling du run
complet.
- Une panne différente a été reproduite une fois avant la frontière focus : le
widget Monaco a été détaché pendant le clic. Elle confirme que cette fixture a
une frontière overlay/DOM instable, mais n'explique pas l'assertion
`activeElement` originale et ne doit pas être renommée en régression #824.
Le gate navigateur complet de la validation combinée reste **non vert**. Les
succès ciblés présents ne le remplacent pas.
## Inputs et identité
Sources du worktree `issue-824-performance`, HEAD `366830f6`, modifications mission
non committées. Node `v25.2.1`, Chromium `153.0.8010.12`, rejets non gérés stricts,
concurrence de tests 1. Aucun produit, test, fixture, dépendance ou configuration
n'a été modifié.
Artefact privé :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-focus-cache-diagnosis/diagnosis.json`,
SHA256 `F9ABE8BEC0A7A4940B9357448A37AC3AAB1A497BC4C8D8267E0495752ADDB5EA`.
Il enregistre les quatre répétitions et les SHA256 des huit inputs lus/exécutés.
Les quatre hashes test/fixture pris avant les runs sont identiques à ceux de
l'artefact final :
- `git-history.test.ts` : `1F779351772617138C12694D2236FFADCB557BA85E070E64A04C1CBEE7CC049F`;
- `fixtures/git-history.tsx` : `6C73B4FC43D1D4C0B32AB52733919751EBA9FB89BA4E905B542D9B8423C1E1EC`;
- `session-rendering.test.ts` : `DE5F5AE9E6F70428860FBD9089E7CC8EF3F87965B6DB6E164EDEEE000626B578`;
- `fixtures/user-html.tsx` : `904C3CA2B72B4CE7A2272762557C3BD2D3E06FCC7BDDC0310B9D044F5538E660`.
Les inputs produit effectivement inspectés et fingerprintés sont
`monaco-diff-viewer.tsx`, `session-view.tsx`, `prompt-input.tsx` et `markdown.tsx`;
leurs hashes complets sont dans l'artefact. Aucune instrumentation source n'a été
nécessaire.
## Focus Git : attribution
### Frontière exacte
1. Le bouton overlay empêche le `mousedown` par défaut afin de ne pas voler la
sélection Monaco, puis appelle synchroniquement `onRequestInsertContext`.
2. `GitDiffView` produit le texte qualifié; `SessionView.handleInsertPreviewComment`
appelle `PromptInputApi.insertComment`.
3. `insertComment` appelle `insertBlockContent`. Celui-ci fait immédiatement
`setPrompt(nextValue)`, puis programme seulement dans `setTimeout(..., 0)` :
`textarea.focus()` et `setSelectionRange()`.
4. Le test clique, lit deux fois `composer.inputValue()` puis évalue immédiatement
`element === document.activeElement`. Les lectures de valeur ne constituent pas
une attente contractuelle de la tâche timer.
Le run complet original a donc démontré exactement ce que ce séquençage autorise :
les deux assertions de contenu passent, puis l'identité de focus est encore fausse
à l'instant observé. Il n'a démontré ni perte de brouillon, ni mauvaise cible, ni
absence durable de restitution du focus.
### Replays présents et nouveaux
La validation combinée avait déjà un replay exact vert et une suite voisine verte.
Sur quatre nouvelles répétitions bornées :
- répétitions 1 et 2 : test complet vert;
- répétition 3 : n'atteint pas l'assertion; timeout de 30 s dans `insert.click()`.
Playwright rapporte que `<html>` intercepte les pointer events, puis que le
bouton overlay est détaché pendant le retry;
- répétition 4, focus seul : test complet vert.
Ainsi l'échec `false !== true` original reste non reproduit dans ce suivi, mais sa
cause de test est expliquée par une absence de synchronisation observable dans le
code. La panne de clic séparée interdit de qualifier la fixture comme parfaitement
stable.
### Disposition minimale
Ne pas changer le produit sur cette preuve. Si un gate navigateur entièrement vert
est exigé, le plus petit ajustement de test est d'attendre causalement que le
textarea devienne `document.activeElement`, dans le délai existant, puis conserver
l'assertion d'identité. Ne pas ajouter de sleep, force-click ou retry global.
La panne overlay distincte mérite, seulement si elle récidive au gate de livraison,
une trace opt-in de `widgetPosition`, sélection Monaco, bounding box et événements
`focusin/focusout`/pointer autour du clic. Elle ne doit pas être masquée par
`click({ force: true })`.
## Cache HTML : attribution
### Frontière exacte
Le fixture monte un `Markdown` avec une part dont `renderCache` contient :
```text
mode = 1:escaped:wrap
html = <div class="cached-html">old sanitized HTML</div>
```
En mode initial `literal(true)`, le mode résolu est `1:literal:wrap`; ce cache est
donc correctement refusé. Après `literal(false)`, avec `escapeRawHtml` toujours
vrai, le mode devient exactement `1:escaped:wrap`. L'effet de `Markdown` doit alors
prendre `snapshot.part.renderCache`, exécuter `setHtml(localCache.html)` et retourner
avant `renderSnapshot()`. L'apparition de `.cached-html` ne dépend donc ni du
chargement du module Markdown, ni de Shiki, ni d'une promesse de highlighting.
Le timeout original signifie que cette transition synchrone n'a pas été observée
dans le DOM. Sans trace de la valeur du signal, du nombre d'exécutions de l'effet,
de l'identité du nœud `#cache`, des erreurs page/module ou des mutations DOM, il est
impossible de distinguer :
- effet Solid non relancé ou runtime optimisé incohérent dans ce serveur Vite;
- fixture/nœud remplacé pendant le run complet;
- autre anomalie de scheduling ou de cache du harness.
Une lenteur ordinaire de rendu Markdown est écartée par le chemin de cache local.
Un défaut produit persistant est affaibli par les preuves : replay exact vert,
suite `session-rendering` complète verte (46 tests), puis trois nouvelles
répétitions cache **3/3 vertes** sur sources identiques.
### Disposition minimale
Aucun patch produit justifié. Ne pas demander davantage de retries ciblés. Si le
même timeout réapparaît dans un prochain gate complet autorisé, capturer dans le
failure artifact, sans modifier assertion ni délai :
- valeur courante de `literal` et compteur d'exécutions de l'effet;
- `#cache.innerHTML`, connectivité/identité du nœud et MutationObserver;
- `pageerror`, console, `requestfailed` et URLs des chunks Vite/Solid chargés;
- mode/cache key résolus avant et après le setter.
Cette trace permettra de tester causalement l'hypothèse de cache d'optimisation
Vite partagé. Introduire un `cacheDir` privé peut ensuite être évalué par contrôle
négatif; le succès isolé seul ne prouverait pas cette cause.
## Exécutions bornées
Commande à deux noms exacts exécutée trois fois, puis focus seul une fois :
```powershell
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 \
--test-name-pattern="^(central diff inserts local lines and revision-qualified history into the real session composer|user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches)$" \
packages/ui/tests/browser/git-history.test.ts \
packages/ui/tests/browser/session-rendering.test.ts
```
Résultats : focus 3 passages complets verts et 1 timeout de clic préalable; cache
3/3 vert. Durées des quatre processus : environ 24,9 s, 22,8 s, 54,7 s et 15,0 s.
Sorties naturelles; la troisième sort avec code 1 sur timeout Playwright. Aucun
fullbrowser, stress, installation, daemon/profil/base utilisateur, restart,
déploiement, commit, push ou sous-agent.
## Décision pour le gate de livraison
Conserver séparément :
1. le fullbrowser 356/3/1 à exit 1;
2. ses retries et suites ciblées vertes;
3. ce diagnostic, qui attribue la course focus au test mais laisse le timeout
cache non qualifié et révèle une panne overlay distincte.
Si le critère de livraison exige un fullbrowser vert, appliquer uniquement la
synchronisation causale du focus après décision du coordinateur, instrumenter le
cache/overlay de manière opt-in au prochain gate complet, puis exécuter **un** gate
figé. Ne pas présenter les replays ciblés comme un gate final vert.

View file

@ -0,0 +1,127 @@
# Issue 824 — synchronisation du test focus et traces de frontière
## Résultat
La seule correction fonctionnelle est dans
`packages/ui/tests/browser/git-history.test.ts` : après les deux assertions de
contenu, le test attend maintenant causalement que le textarea déjà résolu soit
`document.activeElement`, puis conserve l'assertion d'identité existante.
Cette attente utilise le délai Playwright existant. Aucun sleep, timeout explicite,
retry, force-click, changement de sélection ou changement du contrat focus produit
n'a été ajouté.
Le focus corrigé passe **2/2**. Le second run a toutefois reproduit exactement le
timeout `.cached-html` indépendant. Le résultat ciblé global est donc un run vert
puis un run rouge; il ne constitue pas un gate navigateur vert et ne démontre pas
une correction universelle de #824.
## Diff exact
### Synchronisation focus
Après les assertions inchangées du brouillon et du contexte Git :
```ts
const composerElement = (await composer.elementHandle())!
await page.waitForFunction(element => element === document.activeElement, composerElement)
assert.equal(await composer.evaluate(element => element === document.activeElement), true)
```
Le textarea est capturé après l'insertion. L'attente observe uniquement la
restitution asynchrone du focus déjà prévue par `insertBlockContent()`.
### Instrumentation observationnelle, désactivée par défaut
`CODENOMAD_BROWSER_BOUNDARY_TRACE=1` active désormais :
- pour la fixture Git, les événements `focusin`, `focusout`, `pointerdown` et
`click`, le retrait éventuel du widget Monaco, l'élément actif final, la
connectivité/position des widgets et la sélection Monaco disponible ;
- pour la fixture HTML, la valeur `literal`, le DOM/connectivité de `#cache`, les
mutations, les points avant/après setter, le `renderCache`, les ressources de
module pertinentes, les `pageerror`, consoles et requêtes échouées.
En mode normal, la fixture HTML expose directement le setter Solid original. Les
observers et wrappers de trace ne sont créés que lorsque la variable opt-in ajoute
`trace=1`; seul un snapshot en lecture est disponible pour le dump d'échec déjà
existant. Assertions, délais et chemin normal restent inchangés.
Cette instrumentation prépare le prochain gate unique; elle n'a pas été utilisée
pour relancer le cas après le timeout, conformément à la limite de deux runs.
## Validation bornée
Commande exacte, concurrence 1 et noms exacts, exécutée deux fois :
```powershell
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 \
--test-name-pattern="^(central diff inserts local lines and revision-qualified history into the real session composer|user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches)$" \
packages/ui/tests/browser/git-history.test.ts \
packages/ui/tests/browser/session-rendering.test.ts
```
Résultats :
1. exit `0`, 2 pass : focus 12,3 s; cache HTML 9,4 s;
2. exit `1`, focus pass 12,7 s; cache HTML timeout 38,8 s sur
`page.locator("#cache .cached-html").waitFor()`.
Le dump du second run est celui d'avant instrumentation et indique
`state: undefined`; il ne qualifie donc toujours pas la cause cache. Il établit en
revanche que le timeout antérieur est reproductible sur les mêmes sources et doit
rester une frontière ouverte, séparée de la correction focus.
Autres contrôles :
- `npm run typecheck --workspace @codenomad/ui` : exit `0` après instrumentation ;
- `git diff --check` sur les trois fichiers de test/fixture : exit `0`, avec
avertissements de conversion LF/CRLF seulement ;
- aucun fullbrowser relancé.
## Empreintes
Artefacts privés :
- `C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-focus-test-sync/manifest.json`
— SHA256 `8440F3A61D0B59C870E1A64F9AAEFF90F68B5969FD5C77510F82AB2C899C543D` ;
- `run-1.log` — SHA256
`5354F36A00380DC16397A394C09B58FBD25D4AE3D613EF026024986077080E69` ;
- `run-2.log` — SHA256
`0499BDEFAB8EC34349CD2C63E1D0402CB1324AF98FF6567E984F5A1F6B15E63B`.
Le manifest contient les SHA256 avant/après de huit inputs. Seuls les trois inputs
de test autorisés ont changé :
- `packages/ui/tests/browser/git-history.test.ts` ;
- `packages/ui/tests/browser/session-rendering.test.ts` ;
- `packages/ui/tests/browser/fixtures/user-html.tsx`.
Les quatre inputs produit fingerprintés sont strictement identiques avant/après :
`monaco-diff-viewer.tsx`, `session-view.tsx`, `prompt-input.tsx` et `markdown.tsx`.
`fixtures/git-history.tsx` est également inchangé.
Le manifest de la validation combinée et le manifest du diagnostic précédent sont
désormais des preuves **historiques**, car `git-history.test.ts` a volontairement
changé. Ils ne fingerprintent pas ce nouvel état de test.
## Limites et prochain gate
- La panne distincte de clic/détachement du widget Monaco n'a pas récidivé dans ces
deux runs et n'est ni corrigée ni masquée.
- Le timeout cache a récidivé une fois; aucun patch Markdown/Solid/Vite n'est
justifié sans la nouvelle trace opt-in.
- Aucun test au-delà des deux noms ciblés n'a été exécuté. Le fullbrowser précédent
reste non vert.
Après la fin des diagnostics encore en file, exécuter un seul gate navigateur sur
sources figées avec `CODENOMAD_BROWSER_BOUNDARY_TRACE=1` et conserver l'artefact
complet si cache ou overlay échoue. Ne pas ajouter de retry pour fabriquer un gate
vert. Toute correction ultérieure du cache ou de Monaco doit partir de cette trace
causale et rester hors de cette tâche.
## Fichiers volumineux touchés
- `packages/ui/tests/browser/session-rendering.test.ts` : environ 910 lignes, sous
le seuil d'alerte test de 1000 lignes mais proche ; aucun refactor de portée n'a
été entrepris.

View file

@ -0,0 +1,155 @@
# Issue #824 — bounded Chromium HTTP/1 admission qualification
## Result and scope
**No global freeze reproduced in these five isolated scenarios.** Chromium
Windows can progress in session B while session A's prompt/compact ownership
preflight is held. Closing a page or browser context retires the old HTTP/SSE
observers before the upstream handler is released. A new page connects and reads
B before that release; late completion does not forward the abandoned mutation.
No additional product correction follows from this result.
This is **Windows Chromium → Windows Fastify over private loopback HTTP/1.1**,
not Electron Linux → Windows LAN qualification, not a desktop process restart,
and not qualification of an OpenCode 2.0.19 runtime. No OpenCode daemon, database,
provider, shared profile, production backend or installed desktop is used.
Added paths only:
- `packages/ui/tests/browser/http-admission-responsiveness.test.ts`
- `packages/ui/tests/browser/fixtures/http-admission-responsiveness.tsx`
- this report
## What is real versus supplied
- Playwright 1.63.0 launches installed Chromium **153.0.8010.12** on Windows;
Node **25.2.1** runs private ephemeral Fastify listeners.
- The browser addresses Fastify directly. Vite serves fixture modules on another
origin; there is **no Vite/Node proxy** in the business-request/SSE path.
- CDP `Network.responseReceived` asserts `http/1.1` for the real SSE response,
B response, CodeNomad metadata and control witness. Fastify also records request
`httpVersion === "1.1"`. These are not intercepted Playwright responses.
- Production `registerEventRoutes`, `EventBus`, `ClientConnectionManager` and
browser `serverEvents` provide one multiplexed EventSource subscription per
page. Event payloads are fixture-controlled; there is no native event relay or
native compaction event sequence in this experiment.
- Production `registerInstanceProxyRoutes`, request admission, runtime fetch and
`WorktreeDeletionFence` implement admission/cancellation. The official
`@opencode/client@2.0.21` Promise SDK runs in browser **and** backend. Real
browser `createInstanceFetch` performs classification/background scheduling.
- A disposable upstream Fastify service supplies native-shaped HTTP responses,
including the session `{ data }` wire envelope. Its A GET handler deliberately
remains held even after its HTTP response socket closes. This is an induced
preflight wait, **not** real native compaction or provider execution.
- A small ownership manager supplies only the `/fixture` identity and invokes a
real SDK location read. No discovery, filesystem or actual worktree scan is
performed. Shared production primitives are imported rather than copying the
server disconnect fixture's complete fake SDK tree.
- Draft input/session switching are minimal Solid fixture controls. Their switch
performs the real SDK B read. This measures browser event/input/network progress,
not rendering of the full CodeNomad transcript, session store or composer.
- “Info” is the real CodeNomad `/api/meta` route, not native `server.info()`.
`/api/info` is not exposed by the workspace proxy allowlist and was not added.
## Scenarios and causal assertions
Four cases cross prompt/compact with page/context close. Each starts exactly one
SSE plus one A mutation whose real backend SDK session read is held. Before release,
the browser edits a draft, switches/loads B, reads `/api/meta` and reads a control
witness. A stays pending throughout those successful operations.
A **separate fifth case** adds five distinct agent catalogue read intents through
the existing `createInstanceFetch` policy. Only **two** reach upstream and remain
held; three stay queued. Distinct agent URLs avoid Chromium's same-URL cache
coalescing. This is a scheduling stress witness, not a claim that normal UI issues
five such reads on any particular cadence. No six-held-foreground-request fanout
is used to manufacture an ordinary-workflow freeze.
Before releasing any held upstream handler, closing the page/context must produce:
- zero SSE registrations, old downstream mutation responses, backend SDK A
observers, native A HTTP responses and (fifth case) catalogue HTTP responses;
- one observed A admission abort, restored request-abort listener count and no
residual admission/forwarding close listener;
- a completed deletion-fence operation; A is held **before** fence admission,
so this checks non-reservation, not cancellation of an already admitted write;
- successful new-page SSE/B/control reads with no old observer restarted.
Upstream handler closures deliberately remain alive until the test gate is opened
(one handler, or three with catalogues). They are counted separately from HTTP
observers: their intentional retention must not be mistaken for an orphaned HTTP
watcher. After release the test waits for those continuations to finish and checks
zero A forwards, environment writes and connection invalidations, with no new
catalogue dispatch from the destroyed page.
## Executed validation
Existing dependencies/browser reused without installation. From the mission root:
```powershell
node --unhandled-rejections=strict --import tsx --test packages/ui/tests/browser/http-admission-responsiveness.test.ts
node --unhandled-rejections=strict --import tsx --test packages/ui/tests/browser/http-admission-responsiveness.test.ts packages/ui/tests/browser/event-subscriber-isolation.test.ts
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts
npm run typecheck --workspace @codenomad/ui
```
Standalone: **5/5 pass**, zero skips, natural exit ~4.0 s. After adding explicit
held-handler continuation accounting, combined gate: **10/10 pass**, zero skips,
natural exit ~5.1 s (five new cases plus five existing subscriber-isolation cases).
UI source typecheck passes; that tsconfig excludes browser tests/fixtures, which
are instead executed via tsx/Vite/Chromium. Neither a full browser-suite gate nor
full desktop/native-runtime validation is claimed.
The existing server disconnect/admission cases also pass **15/15**, zero skips,
natural exit ~0.8 s, without editing their fixture or product sources. Scoped
no-index whitespace checks on the three new files emitted only Git's LF/CRLF
warnings, no whitespace diagnostics (no-index diff status 1 for new contents).
Descriptive measurements from the combined run (not CPU latency requirements):
| Scenario | Input/B/meta/control progress while held | Close cleanup | Backend peak TCP sockets |
| --- | ---: | ---: | ---: |
| prompt, page close | 92 ms | 13 ms | 3 |
| compact, page close | 79 ms | 16 ms | 3 |
| prompt, context close | 68 ms | 5 ms | 3 |
| compact, context close | 74 ms | 5 ms | 3 |
| compact + background budget, context close | 72 ms | 7 ms | 5 |
Socket counts include idle/preconnect connections and are **not** asserted as busy
connection counts. Page close left one idle pooled TCP socket but no pending HTTP
observer; context close left zero in this run. New pages had one SSE and two TCP
sockets. Assertions are causal (B succeeds before gate release, cleanup occurs
before release); 10/30/60-second harness bounds detect deadlock only, not latency
regression thresholds.
## Remaining Linux → Windows qualification
The smallest useful next experiment needs an approved isolated Windows backend
and native storage/provider, plus the actual Linux Electron build. Bind that private
backend on a test interface restricted to the Linux host; never expose or use the
shared daemon. Record backend/UI/native/Electron/Chromium versions, exact scheme,
proxy/VPN configuration and whether authentication uses the remote-window path.
Concrete read-only external control from Linux, using the approved private URL:
```sh
curl --http1.1 --max-time 10 -sS -D private-meta.headers \
-o private-meta.json "$WINDOWS_PRIVATE_URL/api/meta"
```
Provide the isolated backend's normal authentication if required, without saving
credentials in the report. This curl process does not consume the renderer's
connection pool; compare its progress with renderer Network timings, not as proof
that the renderer is healthy. In Electron collect the owned window's existing
automation/CDP Network events (no fixed debugging port), protocol, connection IDs,
request initiator, queue/stall/connect/TTFB timings and one SSE subscription count.
Start with one real A compaction and B switch/draft/meta reads; count background
reads separately. Compare close/reopen page, close/reopen remote window and full
isolated Electron restart **without restarting Windows backend/native runtime**.
Capture admission abort, pending SDK reads, relay backlog, upstream execution and
backend event-loop progress before/after each boundary. Do not replay a timed-out
mutation. A genuine native 2.0.19 run must identify its binary/storage/provider;
the pinned 2.0.21 SDK is not evidence about that runtime.
The current harness is intentionally loopback-only and self-terminating; changing
`CODENOMAD_BROWSER_PATH` selects a local browser, not a remote backend. A persistent
LAN-controllable version requires a separately authorized fixture/launch change.

View file

@ -0,0 +1,134 @@
# #824 — diagnostic borné du bootstrap MCP navigateur
## Verdict
**Timeout de readiness reproduit une fois sur cinq ; cause non attribuée.**
Quatre reprises passent et une échoue avant les assertions MCP, avec le même
`page.waitForFunction(window.mcpFixture)` à 30 s que le fullbrowser antérieur.
Les cinq processus sortent naturellement ; pas de skip/force-exit/force-click.
Le suivi est terminé dans son budget, mais n'accepte ni la mission ni le gate
fullbrowser, qui conserve ses 356 pass / 3 fail / 1 skip.
La capture a une faiblesse explicite : son plafond commun de 4 000 observations
est saturé par les requêtes de modules **avant les diagnostics terminaux**. Elle
ne permet pas d'exclure une erreur/rejection tardive ni de dire si le module est
bloqué avant son corps, dans `applyUiSettings` ou dans `render`. Aucun défaut
produit confirmé, aucun correctif produit ou harness proposé comme déjà validé.
## Périmètre et méthode
2026-10-01, Windows x64, Node `v25.2.1`, Chromium `153.0.8010.12`, worktree
`D:/CodeNomad/.codenomad/worktrees/issue-824-performance`, HEAD `366830f6`.
`combined-validation` et `validation-closeout` sont déjà rapportées terminées.
**Aucun fichier produit, test ou fixture source édité.** Seul ce rapport est créé
dans le dépôt. Aucun audit ou fullbrowser supplémentaire, aucune dépendance/config/
lock modifiée, installation, commit/push/merge, restart/déploiement, suppression de
sessions, accès au daemon/base/profil utilisateur ou sous-agent.
Artefacts privés :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-mcp-bootstrap-4770677d4da549e98e255c79c7ecdba3/`.
Le script `diagnose.cjs` copie le texte exact de `mcp-motion.test.ts` en privé,
préserve assertions, timeouts, clics et options Chromium/Vite, puis compile cette
copie TypeScript avec l'esbuild déjà installé. Seuls les imports Node des packages
et la racine Vite sont rendus absolus, puisqu'elle n'est plus dans le dossier du
test original. Vite sert toujours les vrais modules/stylesheet et la fixture source.
Observations ajoutées en mémoire dans cette copie, sans catch qui transforme un
échec en succès ni `preventDefault` d'erreur :
- console, pageerror, request/requestfinished/requestfailed ;
- statut, headers/content-type, URL et type des réponses des modules Vite ;
- erreurs et unhandledrejection dans un init-script de page ;
- marqueurs privés par transform Vite : entrée du corps de fixture, avant/après
settings, avant render et après publication de `mcpFixture` ;
- tentative de snapshot terminal dans le finally, puis fermeture originale.
Le cache Vite conserve son emplacement/options ordinaires (`node_modules/.vite`),
pas une nouvelle politique de cache. Le plugin d'observation privé et l'observer
peuvent toutefois influencer timing et clé d'optimisation : ceci n'est **pas** un
A/B causal contrôlé du cache ou du coût de bootstrap. Aucune suite complète
concurrente repérée lors du contrôle read-only préalable ; les ciblés indépendants
du reviewer ne constituent pas une charge de l'hôte maîtrisée.
## Résultats exécutés — cinq reprises, pas davantage
Filtre exact :
`^eight connected MCP servers stay static at rest \(reduce, 150% scale\)$`.
Chaque processus exécute un seul cas avec rejets stricts et concurrence 1. Le
budget total du runner est **84,7 s**, sous dix minutes ; aucune relance après
ces cinq reprises et aucune assertion/durée artificiellement assouplie.
| Reprise | Résultat du cas | Exit | Naturelle | Processus, durée observée |
| --- | --- | --- | --- | --- |
| 1 | pass | 0 | oui | 11,25 s |
| 2 | **fail readiness 30 s** | 1 | oui | 41,03 s |
| 3 | pass | 0 | oui | 12,00 s |
| 4 | pass | 0 | oui | 9,63 s |
| 5 | pass | 0 | oui | 9,46 s |
Le runner global sort exit 1 car une reprise échoue : il ne masque pas le rouge.
Ces cinq exécutions d'un même scénario ne sont pas cinq scénarios de couverture
nouveaux et ne remplacent pas le gate complet antérieur.
## Ce que la capture démontre / ne démontre pas
Dans la reprise rouge :
- Le document `/mcp-motion` et `/tests/browser/fixtures/mcp-motion.tsx` répondent
**200**, respectivement HTML et JavaScript. Le serveur a appliqué la transform.
Une absence totale de réponse de ces deux entrées n'explique donc pas ce run.
- Le préfixe enregistré comprend **1 645 requêtes**, **1 175 réponses 200** et
**1 175 requestfinished**. Les modules optimisés Solid/debug répondent aussi 200.
De nombreux modules sont des icônes `lucide-solid/dist/source/icons/*.jsx`,
conformément à `optimizeDeps.exclude: ['lucide-solid']` existant.
- Les seuls messages console retenus sont `[vite] connecting...` et `connected.`.
Aucun pageerror, requestfailed ou statut >=400 n'apparaît **dans ce préfixe**.
Ceci ne prouve pas leur absence dans la totalité du run.
- Le plafond de 4 000 événements est atteint vers 10,14 s après début du processus
de test. Il est aussi atteint dans les quatre passes (8,42–10,93 s), donc sa
saturation n'est ni un discriminateur du timeout ni une cause de freeze établie.
- Le log Node démontre le timeout après `goto`, sur la readiness, pas une assertion
« huit serveurs ready », ni une animation perpétuelle du composant.
**Limite de capture confirmée** : les snapshots/stages, événements ready et erreurs
tardives partagent le même plafond avec le réseau. Ils ont été perdus, y compris
le snapshot terminal qui aurait permis de localiser la frontière. Ne pas convertir
cette absence d'observations en absence d'erreur produit. La quantité de modules,
une collision/rebuild du cache Vite, l'ordonnancement, la charge de l'hôte ou une
attente settings/render restent des hypothèses, pas des causes démontrées.
Le fixture source substitue l'inventaire Shell et le client MCP ; aucune compaction
native ou connexion à un vrai MCP/OpenCode n'est testée ici. Rien dans ces traces
n'attribue le freeze original #824 à MCP ni à Electron Linux → Windows.
## Empreintes et artefacts
Avant/après chaque reprise : **1 696 fichiers** tracked + untracked non ignorés,
sources/tests/scripts/configs/lock inclus, hors docs/générés/dist/public/deps/cache.
Pas de dérive et mêmes hashes de fichiers que la validation combinée. Le format
du manifeste privé (map path->SHA seulement) diffère du manifeste combiné enrichi.
`results.json` confirme `originalTestUnchanged: true`, `fixtureUnchanged: true`.
- `initial.json`, `<n>-before.json`, `<n>-after.json`, `<n>-result.json` : empreintes
et statuts. SHA initial `c4c4ff2bbc0c022b27258bda2ba085f79062f0cb982341b3ced09d0cd07adaa9`.
- `test-original.ts`, `fixture-original.tsx`, `test-observed.ts` et
`test-observed.mjs`, plus `harness.json` : provenance et modifications privées.
- `<n>.log`, `<n>-trace.json` : logs/traces complets **jusqu'au plafond**, pas traces
complètes de la vie de page. `trace-summary.json` identifie la saturation.
- `2-module-prefix.json` : réponses d'entrée et modules optimisés du rouge.
- `2.log` SHA256 `0830b1ee6d0b74c4640c434529227ce61193fcfe8ca01f41251bef114e264284`.
- `2-trace.json` SHA256 `d414677c96cb7ab5b4e2a79b18eff64fa6bf07539e5e205333c94725157e4332`.
- `diagnose.cjs` SHA256 `a6f4d4702d13624cf8c1123819ec0877da83b8381a07addbd7436fa84f195c1b`.
## Prochain minimum utile — décision du coordinateur, pas une relance ici
Autoriser un nouveau suivi **seulement si l'attribution reste nécessaire** : séparer
les événements critiques dans une réserve indépendante du réseau, conserver toujours
un snapshot terminal/stages/rejections, et agréger les réponses modules par URL/status
plutôt que laisser leur volume éjecter le diagnostic. Avec ce harness corrigé, capturer
une nouvelle occurrence avant toute décision de patch ou expérience causal-cache.
Ne pas changer les assertions, les 30 s, les gestes ni la politique produit pour
obtenir artificiellement du vert. Aucun patch produit minimal ne peut être recommandé
sur les preuves actuellement disponibles.

View file

@ -0,0 +1,165 @@
# #824 — capture critique du bootstrap MCP
## Verdict terminal
**5/5 reprises ciblées passent naturellement, timeout non reproduit.** La faiblesse
du collecteur est corrigée et vérifiée, et les cinq traces critiques sont complètes
jusqu'à fermeture, sans aucun overflow. L'attribution du timeout historique reste
ouverte : aucune nouvelle occurrence rouge n'a permis d'en localiser la frontière.
Travail de capture terminé, pas preuve d'élimination de panne ni gate global vert.
Le fullbrowser historique reste 356 pass / 3 fail / 1 skip ; le rouge MCP précédent
1/5 reste conservé, distinct de ce suivi. Aucune sixième reprise n'est lancée.
## Périmètre
Suivi borné de `browser-mcp-bootstrap-diagnosis`, pas une nouvelle validation
complète. Les dépendances `browser-mcp-bootstrap-diagnosis` et
`browser-focus-test-sync` sont terminales `completed`, sans exécution restante,
avant le lancement. Rapport focus-sync lu : ses deux ciblés sont finis, focus
2/2, mais timeout cache HTML indépendant au second run. Ses trois modifications
de tests/fixture font désormais partie de la nouvelle identité à fingerprinter.
Sources exclusivement :
`D:/CodeNomad/.codenomad/worktrees/issue-824-performance`.
Artefacts exclusivement privés :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-mcp-critical-20261001/`.
Le diagnostic précédent reste intact dans `issue-824-mcp-bootstrap-4770677d4da549e98e255c79c7ecdba3`.
Aucune édition produit/test/fixture/deps/config/lock ni politique de cache modifiée.
Seul ce rapport est créé dans le dépôt. Aucun install, commit/push/merge, restart,
déploiement, suppression de sessions, accès au daemon/base/profil utilisateur ou
sous-agent. Contrôle read-only des processus Node avant lancement : aucun ciblé
browser, runner de validation ou fullbrowser concurrent repéré.
## Seule modification expérimentale : le collecteur privé
Le harness précédent est recopié sans altérer ses imports/racine absolus,
assertions, délais 30 s, gestes, options Vite/Chromium ou marqueurs privés de
fixture. Il sert toujours les vrais modules et styles depuis le worktree.
Le réseau est désormais agrégé par événement/URL/status/type, avec compteur,
première/dernière heure et content-type pour les réponses. Plafond de 8 000 clés
distinctes, compteurs totaux et overflow réseau explicites. Pas de body HTTP lu
ni de modification de chargement ou optimisation lucide.
Réserves indépendantes : erreurs 512, lifecycle/ready 256 et consoles 256. Chaque
réserve compte son propre overflow. Les réponses >=400 sont aussi classées comme
erreurs ; les requestfailed/pageerror/test-error ne dépendent pas du plafond réseau.
Le snapshot terminal a son propre emplacement hors de tous ces plafonds, y compris
un résultat `observation-error` explicite si son évaluation ne réussit pas.
Les erreurs/rejections de page disposent d'un buffer 512 et d'un overflow explicite,
indépendants du réseau ; les cinq marqueurs de fixture restent conservés.
Le finally capture le snapshot avant fermeture, sans absorber l'échec du test.
Les observations peuvent influencer le timing : ce n'est pas un A/B causal du
cache, de l'ordonnancement ou du coût d'import. L'emplacement et les options du
cache Vite ordinaires sont conservés, pas nettoyés ou isolés artificiellement.
## Préflight sans navigateur
`collector-selftest.cjs` passe naturellement : 10 001 réponses synthétiques sur
un plafond réseau de deux entrées provoquent 9 999 overflows. Malgré cela,
pageerror, réponse 504, ready-before et snapshot terminal restent présents dans
leurs réserves ; l'overflow erreur indépendant est également vérifié.
`collector-selftest-result.json` conserve les résultats. Syntaxe du runner vérifiée.
## Budget et qualification
Filtre unique inchangé :
`^eight connected MCP servers stay static at rest \(reduce, 150% scale\)$`.
Rejets stricts, concurrence 1, au plus cinq runs / dix minutes, arrêt automatique
sur la première occurrence rouge avec snapshot/stages critiques sans overflow.
Pas de force-exit, skip, force-click ou assouplissement des assertions.
Le runner fingerprint avant/après les sources produit, tests, scripts, configs et
locks tracked + untracked non ignorés, hors docs/générés/deps/caches. Il conserve
les statuts exacts, heures, logs, traces et leurs SHA256 ; une dérive n'est jamais
présentée comme un gate immutable. Son texte, collecteur, copie observée et code
compilé ont leurs propres hashes dans `harness.json`.
## Résultats frais et localisation observée
Runner `sh_0f9561ee5001s5HSuUnJ0dLtTb` terminé, exit 0 naturel ; notification finale
reçue. Windows x64, Node v25.2.1, Chromium 153.0.8010.12. Cinq processus, un cas
chacun, zéro fail/skip/cancelled. Budget total 54,35 s ; pas d'arrêt sur rouge car
aucun rouge dans ce suivi. Ce ne sont pas cinq nouveaux scénarios de couverture.
| Reprise | Exit naturel | Durée processus | Entrée corps fixture, horloge page | Settings | Render → publication |
| --- | --- | --- | --- | --- | --- |
| 1 | 0 | 12,04 s | 10,323 s | 29,4 ms | 46,0 ms |
| 2 | 0 | 10,20 s | 8,619 s | 32,0 ms | 43,5 ms |
| 3 | 0 | 10,42 s | 8,819 s | 26,2 ms | 41,8 ms |
| 4 | 0 | 10,11 s | 8,637 s | 30,3 ms | 45,0 ms |
| 5 | 0 | 10,40 s | 8,880 s | 29,1 ms | 44,9 ms |
Dans les cinq passes : `fixture-body → settings-before → settings-after →
render-before → fixture-published`, puis `ready-after` observé. Snapshot final
`mcpFixture: true`, document complete, huit ready dots, 66 caractères au root.
La lecture ready prend 87,3–97,7 ms après son lancement ; `goto(domcontentloaded)`
dure 8,625–10,329 s sur l'horloge Node. Ne pas soustraire les horloges Node/page.
**Localisation du coût dans les passes**, pas de la panne : l'essentiel du temps
de navigation observé précède l'entrée du corps de fixture. Cette région comprend
fetch/transform/parse et évaluation des dépendances/imports, éventuellement leurs
attentes ; les traces ne répartissent pas ce coût. Settings et render arrivent
ensuite et achèvent en quelques dizaines de millisecondes. Cela ne prouve pas
que le timeout rouge antérieur bloquait dans cette même région plutôt que dans
settings/render/ready. Aucun profil CPU ni expérience causal-cache exécuté.
Chaque run conserve **1 837 requêtes, 1 837 réponses toutes 200, 1 837 finished**, soit
5 511 clés événement/URL/status/type, au-delà du vieux plafond commun 4 000 sans
perdre le snapshot. Zéro requestfailed, pageerror, test-error, erreur/rejection dans
les stages ; overflow réseau/erreurs/lifecycle/console/stages tous zéro. Ceci porte
sur ces cinq runs jusqu'au finally, pas sur l'ancien rouge ni sur une vie de page
illimitée après fermeture.
Les consoles **ne sont pas exemptes d'erreurs** : un message EventSource MIME
`application/json` au lieu de `text/event-stream` apparaît dans chaque passe,
avec quatorze warnings Solid « computations created outside a createRoot or render »
et deux messages Vite debug. Le MIME est cohérent avec la route fixture existante
qui remplace toutes les requêtes `/api/**` par `{}` JSON. Sa présence dans les cinq
passes ne démontre ni sa causalité dans le timeout ni son innocuité universelle.
Ces consoles et leurs emplacements sont conservés intégralement ; aucun filtre
pour fabriquer du vert.
## Identité fraîche et pièces terminales
Les **1 696 fichiers** ont les mêmes hashes à l'initial, avant/après chaque run et
au terminal ; zéro drift, y compris entre runs. Le SHA256 des manifests privés
`initial.json` et `terminal-manifest.json` est
`4628acfc4df16f8e573da48dab23df3614a7a8a55643466e5e6e8e4f6e80105b`.
Les seuls écarts avec le diagnostic précédent sont les trois fichiers attendus
de focus-sync (`git-history.test.ts`, `session-rendering.test.ts`, `user-html.tsx`).
Ils ne sont pas édités par ce suivi ; les anciens manifests restent historiques.
`harness.json` et `terminal-receipt.json` vérifient les SHA256 exécutés inchangés :
| Input privé | SHA256 |
| --- | --- |
| `diagnose.cjs` | `4ffab45b45c46e5d09e6d8f0e2b2259fe04cdfb41a02689826119fbba9b583bc` |
| `collector.cjs` | `914cfc1b5b8ab02a98b20157a5c1706056f6ab928b37f0f32bee03a8fe4d08b0` |
| `test-observed.ts` | `a76ae27a9fa8ed04c1f0536ab58b809208fda7edb90c7b27b9fff012db0f30da` |
| `test-observed.mjs` | `cce160f88fbba195c702e3134bc5a1a638936054dbe8148fe6c774357b380ca1` |
Test source original SHA256
`5820fe789d7470a5c6607998fbf7d068ed25df61611e496e4eec9968537669ec`,
fixture original SHA256
`f883fa1acd7318b10c5a517e4b1da0983fde384d2581e0046c7232312e299824`.
Les copies originales et leurs fichiers source sont inchangés.
`<n>.log`, `<n>-trace.json`, `<n>-result.json` conservent chacun leur hash vérifié ;
`results.json`, `trace-summary.json` et `terminal-receipt.json` indexent les cinq
runs, phases, réserves/overflows et sorties. `collector-selftest-result.json`
conserve le préflight. Aucun artefact antérieur n'est écrasé.
## Décision minimale suivante
**Pas de nouvelle boucle de diagnostic ni patch produit justifié ici.** Le
coordinateur peut utiliser ces traces complètes et le harness privé exact comme
capture MCP du gate unique s'il l'autorise. Sans occurrence rouge instrumentée,
l'attribution reste explicitement non qualifiée ; si elle est un critère obligatoire
de livraison, c'est un blocage honnête de ce critère, pas un succès déduit des retries.
Ni imports/lucide, cache Vite, charge hôte, settings ou render n'est une cause prouvée.
Conserver séparément cache HTML ouvert, Linux Electron → Windows LAN/2.0.19 et
sortie naturelle stores non qualifiés. La clôture de mission appartient au coordinateur.

View file

@ -0,0 +1,156 @@
# Issue 824 — correction de sérialisation de la trace Git
## Verdict
Le défaut introduit par l'instrumentation opt-in est corrigé uniquement dans
`packages/ui/tests/browser/git-history.test.ts`.
La cause est démontrée : sous le loader `tsx` réellement installé, le callback
TypeScript passé à `page.addInitScript(callback)` devient une fonction dont
`toString()` contient :
```js
const describe = __name(target => /* ... */, "describe")
```
Playwright sérialise le corps de la fonction mais pas le helper module-scope
`__name`. Une page Chromium minimale reproduit alors exactement
`ReferenceError: __name is not defined`, avec le même stack et une trace vide que
le gate final.
Le script est maintenant fourni par `page.addInitScript({ content })` sous forme
de source JavaScript explicite et autosuffisante. Aucun helper global n'est injecté
pour cacher l'erreur. Une validation Chromium minimale du contenu exact extrait du
fichier est verte : aucune erreur page, 200 entrées bornées après saturation, et
les observations focus, pointer/click et `widget-removed` restent actives.
Le gate final précédent demeure **non vert** : 360 total, 358 pass, 1 fail,
1 skip, exit 1 naturel. Aucun fullbrowser ni test ciblé Git/session/MCP n'a été
relancé dans cette tâche.
## Changement exact
Le callback transformable de `addInitScript` est remplacé par la constante
`gitFocusBoundaryInitScript`, un `String.raw` contenant une IIFE JavaScript sans
types ni closure externe. `describeElement` et `pushTrace` vivent entièrement dans
ce contexte navigateur.
Toutes les observations passent par `pushTrace`, y compris `widget-removed` ; la
limite de 200 entrées couvre donc désormais toute la chronologie, et pas seulement
les événements focus/pointer.
Le snapshot final est entouré d'un `try/catch` observationnel. Une évaluation de
snapshot indisponible est journalisée comme telle et ne masque plus une assertion
déjà en cours ; `page.close()` reste exécuté après ce bloc. Aucun changement aux
assertions, à `errors[]`, aux clics, textes, sélections, délais ou à la
synchronisation focus précédemment acceptée.
## Preuve rouge causale
Artefact privé :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-browser-trace-serialization-fix/red.json`.
Le fichier `callback-before.ts` recopie exactement le callback remplacé. Il est
importé par `node --import tsx`; la fonction obtenue est passée à
`page.addInitScript` dans une page `data:` privée.
Résultat :
```json
{
"hasExternalName": true,
"errors": ["ReferenceError: __name is not defined ..."],
"snapshot": { "trace": [] }
}
```
Le champ `serialized` de l'artefact conserve le JavaScript exact, dont
`const describe=__name(...)`. SHA256 `red.json` :
`1A3271EB26B265784BD84EBF2D4A7546508551BEF04237156CAEF779586C117F`.
Ce mécanisme est certain, et non une simple corrélation : la transformation exacte,
le binding externe absent, l'exception Chromium et l'état vide correspondent tous
au gate. L'ajout de `__name` vient de la transformation `tsx` effective ; aucune
hypothèse sur Vite, Monaco ou le produit n'est nécessaire.
## Preuve verte bornée
`validate.cjs` lit `git-history.test.ts`, extrait le contenu exact du
`String.raw`, puis l'injecte avec `{ content }` dans une page Chromium minimale. La
page alterne le focus 260 fois, émet `pointerdown` et `click`, puis retire un widget
`.git-change-context-widget` après installation du MutationObserver.
Résultat :
```json
{
"hasExternalName": false,
"errors": [],
"traceLength": 200,
"hasFocus": true,
"hasPointer": true,
"hasWidgetRemoved": true,
"lastKinds": ["focusout", "focusin", "pointerdown", "click", "widget-removed"]
}
```
Le budget saturé à 200 et l'entrée `widget-removed` finale démontrent à la fois la
borne globale et la persistance des listeners/observer après saturation.
Artefacts :
- `green.json`, SHA256
`D3024DB5252E029100F2C5D99F28A22F5F7AA186B43C254C0F1AC622CB6554F0` ;
- `validate.cjs`, SHA256
`9AB9E82D41BF8FAE98913DBF2BB9F22C8DA48C61335C47A32B04D7857F336087`.
Deux premiers lancements du runner privé n'ont pas atteint Chromium : le premier
ne résolvait pas `playwright` depuis TEMP, le second contenait des séparateurs de
ligne PowerShell littéraux. Ils ont échoué respectivement avec `MODULE_NOT_FOUND`
et `SyntaxError`; le runner final résout explicitement les dépendances depuis le
worktree. Ces erreurs de préparation ne sont pas comptées comme rouge/vert produit.
## Contrôles statiques et empreintes
- `npm run typecheck --workspace @codenomad/ui` : exit 0.
- `git diff --check -- packages/ui/tests/browser/git-history.test.ts` : exit 0,
avec le seul avertissement LF/CRLF existant.
- Aucun fichier produit modifié par cette tâche.
Manifest privé :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-browser-trace-serialization-fix/manifest.json`,
SHA256 `90405BE5AE1A1BF9C328B226AA3634FB7B49B7C5D754A4B23F569193FD551F32`.
Par rapport au gate final :
- `git-history.test.ts` :
`CD4776C4B035FE2C19A80BC7F562FC916A0B4F3388DAD7F1C0A13F683E18DEE4`
→ `DC7C378CC15E54F22F5D7E1A4C485313F0AF9DF536E1544690A33E3B2B2E5264` ;
- `session-rendering.test.ts` reste
`D50A2932039D3E44FA06BF1E02AA75D108A6C80520CE15724D229A1FEBA4E982` ;
- `fixtures/user-html.tsx` reste
`3D7616C20EDCFA32A7388E473C877B89089DA76FA482CD2E91C1160F95AAD631`.
Les quatre empreintes produit suivies restent celles du gate/focus-sync :
- `monaco-diff-viewer.tsx` : `3EB01E2C6D255E41A92FDCACBF946455B7A87293B19F3389D4FAE5C1381C3142` ;
- `session-view.tsx` : `733C02DE156A75ECD0D2F7C6DCD90A25050C0A1FC5D8EFD2F0E975D649C4E9AB` ;
- `prompt-input.tsx` : `9C8BE5BE1336A11E3188F94716DC3BAE6B63C1EB96BF20DF1F31913318345328` ;
- `markdown.tsx` : `51EE39FE0CC2FF5CF4258221B48919EF41AE9C3592450DF9D3FAF4EF0D2C3456`.
Ainsi, parmi les trois inputs qui distinguaient le gate final de la validation
combinée, seul le script Git opt-in change dans cette tâche. Le manifeste immuable
1 696 fichiers du gate reste une preuve historique, pas le manifeste du nouvel
état.
## Limites et prochain pas
La validation prouve la sérialisation, l'absence de `pageerror`, la borne et
l'activité de la capture dans un DOM minimal. Elle ne rejoue pas le composant
Monaco, les assertions Git ni le corpus complet, conformément à l'interdiction.
Le plus petit prochain pas est une décision du coordinateur : conserver le bilan
terminal comme non vert avec ce défaut de test causalement corrigé, ou autoriser
ultérieurement un unique gate d'acceptation sur une composition figée. Aucun retry,
patch produit ou promesse d'élimination universelle de #824 ne découle de cette
preuve.

View file

@ -0,0 +1,261 @@
# #824 — validation combinée fraîche
## Verdict terminal
**Le gate navigateur complet frais n'est pas vert** : 356 pass, 3 fail, 1 skip.
Serveur (après replay), typechecks, builds, assertions stores et fixture native
passent, sur sources inchangées. A/B frais positif. Le replay des trois cas passe
3/3 naturellement ; les huit suites ciblées passent **104/104** naturellement.
Le travail de validation et sa clôture sont **terminés**, pas « tous les gates verts ».
Aucun verdict
« tout vert » ne peut être déduit d'un succès ciblé. Les résultats ci-dessous ont
été exécutés ici, et ne recyclent ni les anciens 348 passes navigateur ni les
mesures des audits précédents.
2026-10-01, worktree `issue-824-performance`, HEAD
`366830f6b4373e6b283cabf7dd04764e87e1cc32`, modifications mission non committées.
Les quatre dépendances de revue sont completed ; la re-review indépendante a clos
M1. Aucun produit/test/config/lock/dependency édité par ce validateur ; seul ce
rapport et les artefacts privés de validation sont sa livraison. Aucun déploiement,
restart utilisateur, commit/push, install ou sous-agent.
## Identité des sources
Artefacts sous :
`C:/Users/Admin/AppData/Local/Temp/opencode/issue-824-combined-b91c8b66fcc840e1909170c5eeb846b9/`.
`initial.json` enregistre **1 696 fichiers** : Git tracked **et** untracked non
ignorés, y compris nouveaux tests/modules/scripts, configs et lockfiles. Parmi eux,
1 379 chemins dans les sources packages, 43 suites browser et 69 scripts. Hash des
octets bruts, pas de normalisation des fins de ligne. Exclusions : Markdown/PDF/docs,
dist/public/node_modules/cache/release/target. Les dépendances installées ne sont
donc pas fingerprintées ni modifiées ; environnement/exécutable Node enregistré.
SHA256 initial :
`16a3d4085dac90c27e892a480ae4c0735eb52782a9add8b0ca6732a640f1a4df`.
Chaque gate publie son manifeste avant/après, son statut, son log et leurs SHA256
dans `<gate>-result.json`. Sur les seize gates/tentatives terminés, **aucune dérive**,
y compris par rapport au
manifeste initial. La comparaison constate l'immutabilité aux bornes ; ce n'est pas
un verrou filesystem interdisant une modification transitoire suivie d'un retour.
## Gates frais terminés
Windows x64, Node `v25.2.1`. Tous les exits ci-dessous sont enregistrés, pas inférés
de l'apparition d'un dernier test vert. Les sous-gates inclus dans une suite complète
ne s'additionnent pas à sa couverture.
| Gate | Résultat | Sortie | Temps |
| --- | --- | --- | --- |
| serveur complet, premier run | 792 pass / 1 fail / 6 skip, 799 tests | naturelle, exit 1 | 27,8 s |
| test Git concerné isolé | 9/9 pass | naturelle, exit 0 | 14,1 s |
| serveur complet, replay | **793 pass / 0 fail / 6 skip**, 799 tests | naturelle, exit 0 | 30,8 s |
| typecheck UI | pass | naturelle, exit 0 | 8,2 s |
| typecheck serveur | pass | naturelle, exit 0 | 4,0 s |
| typecheck Electron | pass | naturelle, exit 0 | 1,2 s |
| build serveur, incluant UI + plugins pruning/automation | pass | naturelle, exit 0 | 26,0 s |
| stores/reducer/admission/pruning | **141/141 pass** | **force-exit**, exit 0 | 7,2 s |
| nouveau test store compactage sans force-exit | 11 assertions affichées pass, pas de résumé final | **timeout 30 s**, SIGTERM privé | non qualifiant lifecycle |
| event-source-handlers | 4/4 pass | naturelle, exit 0 | 0,23 s |
| diff-check tracked | pass, avertissements LF/CRLF seulement | naturelle, exit 0 | 0,04 s |
| fixture native complète, CLI explicite 2.0.21 | pass | naturelle, exit 0 | 63,4 s |
| navigateur complet | **356 pass / 3 fail / 1 skip**, 360 tests | naturelle, exit 1 | 21 min 38 s |
| A/B renderer, trois couples | 30 samples par variante | naturelle, exit 0 | 90,1 s |
| trois échecs navigateur, replay par noms exacts | **3/3 pass** | naturelle, exit 0 | 33,1 s |
| huit suites navigateur ciblées | **104/104 pass**, zéro skip/cancelled | naturelle, exit 0 | 306,4 s |
Le premier échec serveur est conservé intégralement dans `server.log` :
`git-worktree-config.test.ts`, test « checks a redirected main checkout when opened
from an ordinary linked checkout », échoue dans le **afterEach rmSync** ligne 40
sur un répertoire privé avec `EPERM`. Ce n'est pas une assertion Git échouée.
Les mêmes sources passent ensuite le test isolé et le replay complet. La cause du
refus Windows (processus Git/antivirus/autre) n'est pas démontrée ; ne pas effacer ce
run ni revendiquer deux suites complètes vertes.
Les six skips serveur restent les quatre opt-in WSL write (mode 0600, symlink loop,
conflit mode-only, directory-before-rename) et les deux permissions/symlink POSIX.
Ils ne sont pas couverts sur cet hôte. Le gate store emploie la convention existante
`--conditions=browser --test-force-exit`, avec rejets stricts : il qualifie ses
assertions, **pas le nettoyage du processus**. La tentative naturelle du fichier
nouveau n'a pas résolu cette limite connue de revue. Aucun timer arbitrairement
annulé/unrefé, aucun test/produit modifié pour obtenir un exit vert.
Le build réexécute `npm run build --workspace @neuralnomads/codenomad` : clean du
dist généré, Vite UI, copie public, compilation serveur et bundling des deux plugins.
Avertissements non bloquants : imports à la fois statiques/dynamiques, chunks >500 kB
et données Browserslist anciennes. Aucun rebuild/relaunch de l'application installée.
## Qualification native privée fraîche
Commande exacte avec rejets stricts :
```powershell
node --unhandled-rejections=strict scripts/test-session-pruning-native.mjs 'C:/Users/Admin/AppData/Roaming/npm/node_modules/@opencode/cli/bin/opencode.exe'
```
Le script vérifie **2.0.21**, lance son propre enfant `serve`, provider, config,
HOME/XDG et DB privés, et se termine naturellement. Il ne découvre/redémarre/arrête
pas le daemon utilisateur. Fixture conservée :
`C:/Users/Admin/AppData/Local/Temp/opencode/opencode/codenomad-pruning-native-BRHy0F`.
`native.log` confirme subscriber abort/return/throw sans invalider la connexion,
relay FIFO/slow recipients/shutdown, worktrees et cache, vraies routes proxy
prompt/command/Shell/inbox/cancel/Forms/permissions, pièce jointe 5 MiB, counts/search,
outline 1 501 messages, cleanup/retry/restore/fork, découverte et présence du plugin
bundlé. C'est **un fixture complet**, pas un nombre de tests à ajouter aux 799.
Le restart mentionné dans son log ne concerne que son enfant jetable.
Cela confirme le bundle et les intégrations sur runtime privé Windows, pas une vraie
charge utilisateur/provider, ni Electron Linux → Windows LAN/OpenCode 2.0.19.
Les deux anciens fixtures complets et la matrice native compaction du coordinateur
restent des preuves historiques distinctes, non additionnées aux résultats ici.
## Navigateur et A/B
Replay complet lancé avec `npm run test:browser --workspace @codenomad/ui`, budget
60 minutes, sans force-exit/force-click. Avant lancement : demande au coordinateur,
journal ancien non trouvé à son chemin exact, puis vérification read-only des
processus Node : aucune suite `tests/browser|test:browser` encore en cours. Aucun
processus partagé arrêté. Ce replay utilise les sources courantes, pas le vieux gate.
Le run complet termine naturellement (exit 1), aucun cancelled, aucun drift. Le
skip reste « Electron BrowserWindow native zoom retains tab seams and matching
scrollbars » : opt-in `CODENOMAD_TEST_ELECTRON` absent (`tab-chrome.test.ts:116`).
Échecs exacts conservés dans `browser.log` :
1. `git-history.test.ts:590`, « central diff inserts local lines and revision-qualified
history into the real session composer » : assertion ligne 609 `false !== true`
sur l'identité textarea/`document.activeElement` après clic Ajouter au prompt.
Les deux assertions de texte/référence précédentes passent ; pas de perte de
brouillon démontrée par cet échec.
2. `mcp-motion.test.ts:47`, « eight connected MCP servers stay static at rest
(reduce, 150% scale) » : timeout 30 s sur `window.mcpFixture` avant assertion
de géométrie/animation. Cause du non-montage non démontrée.
3. `session-rendering.test.ts:82`, « user HTML mode preserves Markdown, pasted
disclosures and code source and fences legacy caches » : timeout 30 s ligne
104 sur visibilité `#cache .cached-html` après `fixture.literal(false)`.
Les gates compactage/SSE/HTTP/membership et le sample instrumenté sont verts dans
ce même run, mais une régression session-rendering voisine ne l'est pas. Les scopes
ne s'additionnent pas aux 360 tests. Le replay ciblé n'altère ni code, timing bounds,
clics, exits ou assertions et doit être rapporté séparément.
### Disposition des trois échecs au replay
`browser-failures.log` et `browser-failures-result.json` : 3 tests, 3 pass, zéro
fail/skip/cancelled, rejets stricts, exit 0 naturel en 33,1 s. Manifeste avant/après
identique à l'initial. Filtrage des noms exacts dans les trois mêmes fichiers,
**sans modifier leurs fixtures, délais, clics ou assertions**. Ce constat suffit
pour dire « non reproduits dans ce replay », pas « résolus » ni « causes connues ».
| Échec complet | Démontré | Pas démontré / prochain élément minimal |
| --- | --- | --- |
| insertion Git, focus | contenu et référence insérés ; focus non revenu au textarea à l'instant de l'assertion ; replay pass | Ordre/timing exact de focus non capturé. En cas de récurrence, enregistrer activeElement et les callbacks de restitution de focus autour du clic, sans changer l'assertion. |
| MCP readiness | `window.mcpFixture` absent après 30 s dans le complet ; replay pass | Cause d'échec de bootstrap non capturée. Capturer pageerror/console/requestfailed et réponses modules Vite au montage, avant d'attribuer au cache ou au produit. |
| user HTML/cache | `#cache .cached-html` pas visible après passage literal(false) ; replay pass | DOM/cache/promise de rendu à cette frontière non capturés. Capturer DOM, état literal et résolution du rendu avant/après toggle au prochain échec. |
Scheduling/charge de l'hôte, optimisation Vite ou état de cache sont des pistes,
pas des causes démontrées. Aucun patch proposé pour fabriquer un gate vert. Une
nouvelle suite complète éventuelle relève de la décision du coordinateur après
attribution ; le suivi de clôture ne lance aucune suite supplémentaire.
### Suites ciblées existantes récupérées à la clôture
Le runner déjà lancé `validate.cjs browser-failures focused` (shell
`sh_0f93a730b001Ofu9KyF5KkL76a`, PID 41540 lors du contrôle) est terminé ; son
résultat final a été reçu, pas inféré du dernier test affiché. Aucun nouveau run
de validation ou audit n'a été lancé lors de `validation-closeout`.
`focused-result.json` : exit 0 naturel, rejets stricts, 306,4 s, sources avant/après
et par rapport à l'initial inchangées. `focused.log` : **104 tests, 104 pass, 0 fail,
0 skip, 0 cancelled**. Détail non additif : compactage 5, isolation SSE 5, HTTP/1
admission 5, render-cost 13, history-navigation 21, session-rendering 46, task-copy 6,
tool-images 3. Le cas user HTML passe donc aussi dans sa suite voisine complète,
pas seulement sous le filtre des trois retries. Sample 80 shells sans remount et
compteurs/contenus compactage exacts restent vérifiés. Ni ce gate, ni les trois
retries ne changent le statut du fullbrowser à exit 1.
## Pièces terminales et décision suivante
`terminal-gates.json` indexe les seize statuts, commandes exactes, heures, sorties
naturelles/forcées, SHA256 des logs/manifests et dérives. Le runner privé final est
conservé sous `validate.cjs`, SHA256
`66d119cfe89bd824e3568e5a0447106e06fc5633c2cb0456a86515d9caecb064`.
Principaux logs SHA256, dans le répertoire d'artefacts ci-dessus :
| Fichier | SHA256 |
| --- | --- |
| `browser.log` | `19099de5e3c3ec694334c4667575715d8dc2dc906f80e558cf54773ea0880a1b` |
| `browser-failures.log` | `6035e5c53b01dc30cc936d6183d8625ebda890908c5ffe7b24d63e9c8010f130` |
| `focused.log` | `bac7cadaf82a5cbf238851a1f5f86eac1b6b431352c0dc5e3161e9db75752628` |
| `server.log` (premier échec) | `d8660dcba0935f9a94fdb965eb9a7723a0f9d8000db18e6ea79bbcce96c55247` |
| `server-rerun.log` | `8fafb4e813b85cfd8abc74bd7e1e1f6f4b4a9df38bb4a6f7934d9781923f84de` |
| `native.log` | `e74886aee42300ee4543a6f2518bc8b714aeec4922f428f4f8ea113efa994ee7` |
| `ab.log` | `0d2e7cb864ff89060d552a4aafa3ed0d1da7f1ba2ca17c7fa7068b1d32be9205` |
Le suivi propriétaire a récupéré ces artefacts, finalisé seulement ce rapport et
son index privé, sans edit produit/tests/config/deps, installation, merge, commit,
push, suppression de sessions, restart ou déploiement. Les travaux de validation
peuvent être rapportés `completed` ; **la qualification globale reste non verte**.
Le coordinateur décide de la clôture de mission, pas ce validateur.
Prochain minimum utile : conserver les trois preuves d'échec global et les retries
séparés ; si un fullbrowser vert est un critère de livraison, autoriser un suivi
instrumenté des trois frontières décrites plus haut, puis décider d'un nouveau
gate complet figé. Ne pas élargir les corrections #824 sur simple hypothèse de
flake. La sortie naturelle stores et Linux Electron → Windows LAN/2.0.19 demeurent
des limites explicites, pas des gates implicitement acquis.
### A/B CPU du postimage M1 actuel
`node scripts/test-render-cost-audit.mjs 3` passe après le fullbrowser : trois
couples alternés baseline/fixed, 80 outils × 8 000 caractères, trois warmups puis
dix deltas chacun, **instrumentation off**. SHA produit testé
`f07ce171a6b1d3394ce94be496b0abd67f8bc0bb48e946eb9173a681c844f401`.
Baseline `47f9e43e` a le même blob renderer que `366830f6` (preuve précédente).
| ms/delta, médiane [min–max], écart-type échantillon | Baseline | Postimage courant |
| --- | --- | --- |
| main-thread TaskDuration | 324,57 [168,06–743,53], 173,67 | 16,94 [9,93–50,33], 11,27 |
| ScriptDuration | 14,51 [7,88–46,20], 9,03 | 0,25 [0,11–32,37], 9,34 |
| dispatcher sync | 12,05 [6,70–19,90], 3,70 | 0,20 [0,10–0,50], 0,08 |
| layout | 2,88 [1,51–4,27], 0,55 | 0,08 [0,07–0,23], 0,04 |
| recalcul style | 10,20 [5,74–13,91], 1,82 | 0,13 [0,10–0,30], 0,05 |
30 échantillons par variante. CDP cumule jusqu'à deux animation frames ; ce n'est
pas une mesure de latence-frame, FPS, heap, débit ou Electron production. Host
activity non contrôlée ; pas de seuil absolu CI. Ces chiffres sont nouveaux et
séparés des A/B antérieurs au correctif membership. Le fullbrowser instrumenté
indépendant compte **80 clones / 0 added / 0 removed** dans chacun de dix deltas,
avec assertion anti-remount ; les valeurs zéro du run instrumentation off ne sont
pas des compteurs de travail réel.
Artefact A/B :
`C:/Users/Admin/AppData/Local/Temp/opencode/codenomad-render-cost-srSQOf/metrics.json`,
SHA256 `c72a491eba14d5e71178ba2895a7304ce07f98a02d340a53ef9f75f4424b7cd0`,
six logs bruts adjacents. `generated-and-ab-hashes.json` identifie aussi les deux
plugins bundlés utilisés par la reprise native.
## Limites et tailles signalées
- Pas de qualification Electron Linux, LAN/VPN ou runtime 2.0.19, ni preuve
d'élimination universelle du freeze signalé. Le test HTTP/1 Chromium est Windows
loopback ; native et browser constituent des périmètres distincts.
- Buffer compactage sans plafond explicite octets/événements dans une tâche sans
yield ; le délai 250 ms n'est pas une garantie de mémoire bornée.
- Scan task structurel à chaque révision conserve les bornes 10 000/200 mais peut
coûter du CPU même sans changement de membership. Le sample 80 est borné, pas
une mesure du pire cas ni de heap/FPS/production desktop.
- Un pas SQLite reste synchrone malgré le budget coopératif du scanner outline.
- Le hashing async utilise toujours le pool libuv et le CPU/mémoire disponibles.
Surfaces modifiées par la mission au-dessus du seuil de signalement (~500 lignes),
sans refactor de taille par cette validation :
- `packages/server/src/server/http-server.ts` ~2 346 lignes ;
- `packages/server/src/workspaces/manager.ts` ~1 240 ;
- `packages/ui/src/stores/instances.ts` ~2 093 ;
- `packages/ui/src/stores/opencode-data.ts` ~828 ;
- `packages/ui/src/components/tool-call/renderers/task.tsx` ~569.

View file

@ -0,0 +1,124 @@
# Issue #824: compaction delta reduction correction
## Outcome and scope
Native compaction fragments now aggregate **before** the OpenCode Solid reducer,
not merely before the visible transcript projection. No server, transport,
`task.tsx`, daemon, user profile or desktop deployment changes belong to this
correction. The preceding large synchronous backlog is diagnostic evidence,
not an acceptance gate or proof of real provider delivery frequency.
The implementation follows installed `@opencode/client@2.0.21` declarations and
its actual Solid reducer: `session.compaction.delta.data.text` appends to the
running row's `summary`; `ended.data.text` replaces it authoritatively; `failed`
replaces the running row with an error record. Durable start/end metadata is not
changed or synthesized.
## Product changes
- `stores/compaction-delta-buffer.ts`: one plain accumulator and one 250 ms timer
per instance/session. It uses the exact native delta type and joins chunks in
arrival order. The interval begins on the first chunk, so later chunks cannot
postpone publication indefinitely.
- `stores/opencode-data.ts`: incoming fragments immediately advance CodeNomad
message/full-data revisions and fence SDK reads, but emit one aggregate to
the reducer per interval. Aggregate reentry does not advance admission twice.
Same-session non-delta events flush preceding chunks to retain native order;
unrelated sessions/instances cannot force a flush. End/failure, delete,
committed revert, pruning invalidation, disposal and reconnect cancel pending
content where its authority has ended. Idle flushes before reducer retirement.
- SDK history reads flush pending chunks before establishing read authority and
before accepting responses. Otherwise a native page fetched after admission
could already contain buffered text that would be appended a second time.
A stale pre-delta page is rejected and receives the existing trailing read.
- Inactive sessions with neither a resident transcript nor a native observer
do not materialize compaction payloads. Only their scalar admission fences and
an unfinished-compaction marker remain. Final activation uses the native page.
Activation during an unfinished skipped start recovers the running row via the
existing authoritative resync path, rather than inventing a start/summary.
The SDK's 20-row resync preserves the already hydrated native 200-row window.
Subsequent streaming resumes interval reduction without duplication.
- `stores/instances.ts`: remove the old post-reducer timer. Deferred aggregate
application projects once immediately; inactive loaded views remain invalidated
but are not hydrated. Client replacement/removal destroys the owned native
projection, including its pending buffer, rather than cancelling only the
now-removed visible projection timer.
## Deterministic red/green evidence
The browser fixture instruments the real native emission and visible projection
boundaries through a test-only Vite transform. There is no product telemetry API,
mock reducer or render reimplementation. Both the full stylesheet and real
`SessionView` are mounted. HTTP/client responses are private fakes, and browser
requests outside loopback are blocked.
| Contract | Baseline `366830f6` | Corrected |
| --- | ---: | ---: |
| 128 tiny active chunks, immediate reducer emissions | 128 | 0 |
| Same burst, after first interval | 128 native emissions | 1 emission + 1 visible projection |
| 8 chunks spaced by 10 ms, first interval | 8 emissions | 1 emission + 1 projection |
| Second spaced interval | another 8 emissions | another 1 emission + 1 projection |
| 128 chunks for never-loaded inactive compaction | 128 emissions | 0 emissions, 0 projections, 0 message-list reads |
| Running compaction activated midway, subsequent 2 chunks | 2 emissions | 1 emission + 1 projection, exact cumulative text |
| Loaded but now inactive session, 4 chunks | 4 emissions | 1 emission, 0 hidden-view projections |
All five baseline browser assertions fail by exact counts, not timing or timeout.
All five corrected cases pass, including exact authoritative final text,
remount, immediate per-arrival revisions, no duplicate chunks, and active-session
isolation. Total active burst text is only 530 characters, not the preceding
32 MiB corpus. Spaced delivery uses genuine distinct task/timer intervals through
the native dispatcher, not one giant callback.
Descriptive ingress sample: baseline 128 chunks took **1.6 ms**, corrected
**0.4-0.5 ms** on the same host with other mission activity. These are sampled
development-build dispatcher durations, not controlled CPU benchmarks, frame
latency, or a claim of eliminating every freeze. No Long Task threshold remains.
## Executed gates
```powershell
# Current browser regression (5 pass)
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/compaction-responsiveness.test.ts
# Neighboring real-renderer/window/navigation regressions (67 pass)
node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/history-navigation.test.ts packages/ui/tests/browser/session-rendering.test.ts
# Native reducer/admission/pruning gates (141 pass, including 11 new compaction cases)
node --conditions=browser --import tsx --test --test-force-exit packages/ui/src/stores/opencode-compaction.test.ts packages/ui/src/stores/opencode-data.test.ts packages/ui/src/stores/opencode-data-idle.test.ts packages/ui/src/stores/opencode-data-settlement.test.ts packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-pruning-pagination.test.ts packages/ui/src/stores/session-pruning-projection.test.ts packages/ui/src/stores/session-pruning-events.test.ts
npm run typecheck --workspace @codenomad/ui
git diff --check
```
All commands above pass. The first combined browser invocation had a 120 s
harness timeout after 17 passing cases; it is not counted as complete. Re-running
the neighboring suites with a 360 s allowance completed all 67 cases naturally.
Unit gates use the repository's browser-condition/force-exit convention because
imported store timers persist. Diff check has only ordinary LF/CRLF warnings.
To reproduce baseline red without changing the checkout, extract the two
`366830f6` blobs `packages/ui/src/stores/instances.ts` and
`packages/ui/src/stores/opencode-data.ts` into a private temporary directory.
Set `CODENOMAD_COMPACTION_BASELINE` to it, then run the five browser cases. Vite
substitutes those blobs read-only and applies the same exact counters. Leave the
variable unset for ordinary corrected runs. Both old and corrected code use the
same dependencies, components and fixtures.
## Remaining limits / review targets
- This establishes avoided reactive work under modest separate event delivery;
it does not qualify real EventSource wire traffic or Electron/Linux against a
Windows backend. The companion transport/native diagnostics remain distinct.
- Same-session non-delta events and native reads intentionally force a flush.
A workload continuously interleaving those boundaries can reduce the coalescing
ratio; ordering and read authority win over a universal four-flushes/s promise.
- Mid-compaction activation uses the existing quiet/revision-stable native resync
policy. Sustained arrivals can delay its authoritative catch-up; the already
hydrated visible native page remains displayed. This patch does not replace
that broader starvation policy or merge arbitrary in-flight history snapshots.
- No user database, daemon, provider calls, native-host timing, heap soak or
deployment/restart was used. Source/token loss boundaries are covered by mocked
deterministic native records, not an end-to-end real provider compaction run.
- File-size warning: touched `stores/opencode-data.ts` is approximately **828**
lines and `stores/instances.ts` **2,093** lines. The new narrow buffer module is
56 lines; no size-only refactor was attempted.

View file

@ -0,0 +1,112 @@
# Issue 824 — revue des barrières d'événements
## Verdict
Revue indépendante, bornée au diff depuis `366830f6`, de
`packages/ui/src/lib/server-events.ts`,
`packages/server/src/workspaces/opencode-service.ts` et de leurs nouveaux tests.
La correction serveur est sûre pour le modèle de partage réel de
`@opencode/client` 2.0.21 : l'arrêt d'un abonné ne détruit plus la connexion
logique partagée, tandis qu'une vraie fin ou erreur amont invalide encore la
génération qui l'a produite. La correction UI isole correctement une exception
synchrone levée directement par un callback et conserve l'ordre FIFO. Aucun
blocage fonctionnel n'a été trouvé dans ces deux corrections.
Il reste toutefois une limite UI confirmée et une faiblesse de diagnostic à ne
pas présenter comme entièrement résolues.
## Constats
### M1 — une erreur Solid différée au flush échappe encore à l'isolation par abonné
`ServerEvents.notify()` entoure l'appel direct du callback, mais
`dispatchBatch()` entoure tous les callbacks d'un `solidBatch`. Une dérivation
Solid déclenchée par un setter peut donc lever seulement lors du flush final de
`solidBatch`, après le retour de `notify()`. Cette erreur sort à la ligne du
`solidBatch`, puis est capturée par le `catch` englobant de
`attachEventSourceHandlers`, qui la journalise à tort comme `Failed to parse
event`.
Reproduction isolée avec la distribution navigateur de Solid : un
`createMemo` qui lève après `setSignal()` imprime `after-set`, puis l'exception
est capturée autour de `batch()`, pas autour du setter. Le fixture navigateur
actuel ne couvre qu'un `throw` direct dans le callback et affirme justement
l'absence de `Failed to parse event`; il ne détecterait pas cette variante.
Impact borné : les callbacks natifs et typés du même lot sont invoqués avant le
flush, donc l'événement n'est pas supprimé pour ces abonnés. En revanche, le
flush réactif peut être interrompu et le diagnostic accuse le transport. Le
plus petit suivi utile est un test navigateur où un abonné écrit un signal dont
une dérivation lève, puis une décision explicite sur la frontière de batch
(isoler aussi le flush par abonné, ou au minimum séparer le diagnostic de
dispatch du parsing).
### L1 — le diagnostic ajouté est désactivé par défaut
`log.error()` utilise le logger `debug` de l'espace `sse`; les espaces sont
désactivés par défaut. Les tests remplacent directement `getLogger("sse").error`
et prouvent que la méthode est appelée, mais pas qu'un utilisateur ou un rapport
de diagnostic voit l'échec en configuration normale. De plus, `kind` distingue
le type d'événement mais pas le bucket wildcard/typé ni l'abonné fautif.
La capture est préférable à la rupture du flux et ne constitue pas un bug de
réactivité. Si ces exceptions doivent être exploitables sur le terrain, ajouter
un canal borné réellement collecté et un identifiant de catégorie d'abonné,
sans contenu d'événement ni rejeu.
### L2 — course abort local / panne amont non déterminisée
Le choix `if (!signal?.aborted) invalidateConnection(connection)` donne à
l'abort local la priorité lorsqu'il est simultané à une erreur de `next()`.
Avec `SharedEvents`, l'abort retire l'abonné local; une panne physique peut alors
être observée seulement par un autre abonné direct, sans invalider immédiatement
`OpenCodeSharedService`.
Le risque paraît faible et auto-récupérable : la tentative suivante du bridge
rouvre le flux partagé; son échec non annulé invalide alors la connexion, et les
mutations ordinaires invalident également sur erreur. Ne pas modifier la logique
sans reproduction. Ajouter au besoin un test à barrières contrôlées où abort et
rejet amont concourent, puis vérifier qu'une reconnexion échouée renouvelle bien
le client.
## Propriétés vérifiées
- Le SDK 2.0.21 partage un seul flux physique et maintient des files par abonné;
`return()`/abort suppriment seulement l'abonné et arrêtent la source uniquement
quand il ne reste aucun abonné.
- `invalidateAfterStream()` distingue correctement :
- erreur/EOF de `iterator.next()` non annulé : invalidation;
- `return`, `throw` consommateur ou abort local : nettoyage par
`iterator.return()` sans invalidation;
- événement d'une ancienne génération : `assertCurrent()` rejette avant le
`yield`, et l'invalidation conditionnelle ne peut pas évincer le remplacement.
- Le `finally` appelle toujours `iterator.return()`; avec le SDK réel cet appel
est résolu sans rejet.
- Le dispatch UI continue après un `throw` direct, sans rejeu, pour les abonnés
wildcard, typés, open et status; le test FIFO/unsubscribe traverse le vrai
EventSource, les routes SSE et le réducteur natif.
- Les callbacks enregistrés dans le produit sont synchrones; aucun abonné
`async` actuel ne crée de rejet de promesse non capturé à cette frontière.
## Validation exécutée
- `node --import tsx --test --test-concurrency=1 packages/ui/tests/browser/event-subscriber-isolation.test.ts` — **4/4 vert**.
- `npm exec --yes tsx -- --test packages/server/src/workspaces/subscriber-lifetime.test.ts packages/server/src/workspaces/opencode-connection-generation.test.ts` — **10/10 vert**.
- `node --import tsx --test packages/server/src/workspaces/opencode-service.test.ts` — **14/14 vert**.
- `node --import tsx --test packages/server/src/workspaces/instance-events.test.ts packages/server/src/workspaces/instance-event-routing.test.ts` — **31/31 vert**.
- `npm run typecheck --workspace @neuralnomads/codenomad` — vert.
- `npm run typecheck --workspace @codenomad/ui` — vert.
- `git diff --check` sur les fichiers revus — vert (seuls avertissements de
conversion LF/CRLF du worktree).
Le lancement global `test:browser` avec un filtre placé après le glob a été
interrompu à 120 s car le runner a commencé d'autres fixtures; il ne constitue
pas un échec de test. Le fichier navigateur ciblé a ensuite été exécuté seul et
est vert.
## Taille des fichiers touchés
Aucun fichier produit revu ne dépasse le seuil d'avertissement :
`server-events.ts` ~219 lignes et `opencode-service.ts` ~360 lignes. Les nouveaux
tests font respectivement ~123 et ~116 lignes.

View file

@ -0,0 +1,139 @@
# Issue #824: HTTP preflight cancellation
## Scope and result
Baseline: `366830f6`; original red diagnostic retained in commit `1ece148c`.
This fixes **orphaned HTTP preflight observers**, not the original global freeze.
Initial implementation is in `issue-824-transport`; the seven paths are now
integrated uncommitted in `issue-824-performance`. No shared daemon, database,
profile, desktop restart or deployment is used.
When prompt/compact waits on a native ownership read, disconnecting the desktop
previously left that read active because disconnect tracking began only after
authorization. The original HTTP fixture failed for both operations while an
independent session answered in 12/3 ms. This shows a cancellation defect, not
proof that native compaction stalls that read in the user's environment.
## Small correction
- `server/request-admission.ts` observes request `aborted` and incomplete
response `close` from the start of the instance proxy handler. It does not
observe ordinary request `close`, which can mean a completely read POST body.
- `http-server.ts` propagates the signal to profile negotiation, session,
project, PTY/Shell and supported location-ownership native reads. Waiting on
shared acquisition or non-cancellable filesystem/inventory work races only
this observer against cancellation; it neither cancels nor invalidates the
shared service. Late continuations cannot advance admission.
- The scope is disposed on every return/error. Mutation fence acquisition and
environment/native dispatch have abort guards. The existing 15-second
environment deadline and two-second advisory Git-context deadline remain;
there is no new timeout on prompts, compaction or execution.
- The existing forwarding scope installs its response-close handler before
dispatch, without an asynchronous gap; the early scope remains until that
handoff settles. `compatibility/proxy.ts` now refuses dispatch when its initial
downstream check already found a disconnected response.
- No interrupt, replay or daemon cancellation is added. Disconnect after an
accepted prompt/compact continues to abandon its HTTP response as before;
an upstream operation already admitted can finish independently.
- `WorkspaceManager.ownsLocation` accepts an optional signal for its native
location check. Existing directory and identity authorization is unchanged.
## Executed validation
Windows, Node 25.2.1, private ephemeral loopback listeners. The junction created
for the earlier diagnosis was removed with `cmd /c rmdir node_modules` (junction
only), followed by a clean `npm ci` in this worktree. The prior missing-dependency
typecheck was an installation-state limitation, not a demonstrated source defect.
`npm ci` reports 49 dependency vulnerabilities; no out-of-scope upgrade is made.
Commands:
```powershell
npm ci
npm run typecheck --workspace @neuralnomads/codenomad
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts packages/server/src/server/__tests__/instance-proxy.test.ts packages/server/src/server/__tests__/instance-proxy-legacy.test.ts packages/server/src/opencode/compatibility/proxy.test.ts
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
$env:TMP=$env:TEMP
node --unhandled-rejections=strict --import tsx --test "packages/server/src/**/*.test.ts"
git diff --check
```
- Server typecheck passes after clean installation.
- Focused run: **65/65 pass**, natural exit. Twelve real-HTTP regressions and
three scope tests include actual Promise SDK HTTP cancellation for prompt and
compact, independent-session response while a preflight is held, delayed
connection/profile/ownership, pre-environment snapshot, environment write,
no forwarding after abort, no connection invalidation/replay, admitted
operation completion, deletion-fence release and listener disposal.
- Timing thresholds were removed from the independent-session assertion: it
must finish while the first read is causally held. Descriptive final aggregate
latencies are **22/12 ms**, not a performance guarantee.
- Entire server gate: **782 tests, 776 pass, 0 fail, 6 declared skips**, natural
exit in ~34.7 s. Skips: four opt-in native WSL write tests and two POSIX file
permission/symlink tests. This count includes the focused cases, not additional
distinct coverage. Full log: `sh_0f8691e64001fwNIml1REbA1tC.out` in the harness's
managed shell storage.
- No full desktop/UI build or native Linux-to-Windows qualification is claimed.
## Remaining limits
Shared service acquisition and shared inventory work may continue after one
observer leaves; cancelling those would harm other consumers. The local route
retires immediately and refuses any late mutation admission. A buffered request
body is parsed before the route handler; this patch does not change parser cost.
Next: independent review of cancellation/forwarding ownership, followed by
integration validation. Separately measure real compaction, native session-read
latency and Chromium HTTP/1 connection occupancy using a private runtime. This
patch alone does not establish or eliminate the reported application-wide freeze.
Touched oversized sources: `server/http-server.ts` ~2346 lines,
`workspaces/manager.ts` ~1240 lines. No unrelated size-driven refactor is made.
## Integration validation
Source: `d9598e42` on `fix/issue-824-transport`, including the original diagnostic
from `1ece148c`. Destination:
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance` at baseline `366830f6`.
All seven paths were applied with the patch tool, without cherry-pick, commit or
push. The three modified production files matched the untouched destination
baseline before integration. All seven Git blob hashes then matched the source
commit exactly; only this report was subsequently extended with integration
evidence. No neighboring concurrent changes were rewritten.
Integrated paths:
- `packages/server/src/server/http-server.ts`
- `packages/server/src/workspaces/manager.ts`
- `packages/server/src/opencode/compatibility/proxy.ts`
- `packages/server/src/server/request-admission.ts`
- `packages/server/src/server/request-admission.test.ts`
- `packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts`
- `dev-docs/issue-824-http-admission-fix.md`
The existing clean integration dependencies were reused without installation.
Executed from the destination, Windows / Node 25.2.1:
```powershell
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts packages/server/src/server/__tests__/instance-proxy.test.ts packages/server/src/server/__tests__/instance-proxy-legacy.test.ts packages/server/src/opencode/compatibility/proxy.test.ts
npm run typecheck --workspace @neuralnomads/codenomad
git diff --check -- packages/server/src/server/http-server.ts packages/server/src/workspaces/manager.ts packages/server/src/opencode/compatibility/proxy.ts
```
Results: **15/15** scope/disconnect and **65/65** focused pass with natural exits;
server typecheck and scoped diff-check pass. The 15 cases are included in the
65-case run, not distinct extra coverage. Descriptive witness latencies are
12/9 ms in the first run and 10/9 ms in the focused run. No full integration
server gate was rerun here; the coordinator's fresh aggregate validation follows
the remaining integrations.
Guard verification: the outer entry checks abort before acquisition begins;
`wait()` checks again after each resolved boundary before the caller continues;
`clientForRequest()` checks before allocating shared-client work. Explicit guards
precede mutation-fence entry, environment write, fetch acquisition handoff and
forward dispatch. There is no asynchronous gap between the final forwarding
guard and installation of its close listener. Late shared completion is exercised
by held connection/profile/ownership/environment tests and cannot advance to a
later effect. Work already admitted to the upstream remains outside this local
observer's interruption authority.

View file

@ -0,0 +1,280 @@
# Issue #824 — private native compaction isolation
## Result
**No cross-session service freeze reproduced in this bounded scenario on
OpenCode 2.0.21 / Windows.** A genuine OpenCode session compaction waited on a
deliberately held loopback summary response. While it was still waiting,
session B accepted a prompt, executed its model request and completed
successfully. Native session reads, activity and server info remained available.
The subsequent large-context qualification below sends verified 2/8/32 MiB
tool payloads from **A itself** to its compaction provider. It observes transient
pre-provider latency (up to ~85 ms in the outline-disabled matrix), but no
persistent cross-session freeze. This supersedes only the original size limit,
not its separate outline measurements.
This result separates provider waiting from service starvation; it does not
identify or disprove the reporter's root cause. It does not qualify OpenCode
2.0.19, Electron Linux, a remote Windows HTTP backend, or compaction work beyond
the bounded synthetic sizes below. Only the explicitly supplied 2.0.21 executable was tested; no other
runtime was installed or discovered.
## Fixture and isolation
Added `scripts/test-compaction-isolation-native.mjs` (383 lines). It follows the
existing isolated native-pruning fixture architecture:
- Requires an absolute CLI path. Runs `--version` and a child
`serve --hostname 127.0.0.1 --port 0`, never `service start/restart` or service
discovery. Authenticated server metadata must match the created child's PID
and executable version.
- Gives that child fresh HOME, USERPROFILE, XDG roots, config directory and
explicit database, all beneath the approved temporary directory. Project
config discovery and model-catalog fetching are disabled.
- Uses only synthetic prompts/data and an HTTP loopback provider. Fixture hooks
add request-kind/session headers and one inert tool; they do not override
compaction results. A real provider request with kind `compaction` is held and
then streams the summary through OpenCode's normal completion path.
- Builds the current session-pruning source into a temporary plugin bundle,
explicitly configured against the private database. This tests outline work
in the real native plugin host, not just an in-memory standalone reader.
- Retains private `result.json` and `server.log` evidence. Finally aborts its
event subscriber, releases fixture gates, stops only its child, awaits its
exit and closes the provider connections/server. The script exits naturally.
- No product edits, shared daemon/base access, user-profile mutation, commit,
deployment or restart occurred.
Contract sources consulted before fixture adaptation: V2 client/API/plugin
guides and the installed `@opencode/client@2.0.21` generated declarations
(`session.compact/wait/get/active`, import/export and event timestamps), plus
`@opencode/plugin` session HTTP hook declarations. Configuration was checked
against the V2 config guide, not the legacy JSON schema.
## Procedure and causal assertions
1. Create and warm A/B with real native assistant/tool messages.
2. Outside the measured intervals, import a separate outline corpus of
128 assistant tool messages × 256 KiB textual output (~32 MiB).
3. Submit A's compaction and observe its real provider request and
`session.compaction.started`. Keep the response gated.
4. Sample native info, A/B get and activity concurrently four times.
5. Submit B's prompt and separately gate its primary provider request. Assert
native activity reports **both sessions running**. Release B only; await its
successful outcome and assistant conclusion while A's wait remains pending
and no A compaction-ended event exists.
6. Run the real outline RPC against the separate corpus during A's provider
wait; concurrently sample native `/api/info` as an external heartbeat.
7. Keep A held for another second, verify B get/info again, then release A.
Require a completed compaction message in native context and an ended event,
with no compaction-failed event.
The test has request/condition deadlines, but does not assert a fragile
millisecond performance threshold. Its main assertion is causal: B completes
**before the only mechanism that releases A is invoked**.
## Original small-context measurements
Two complete measured runs with the one-second extended hold:
| Measurement | Run `qAF00E` | Final run `cb4Y79` |
| --- | ---: | ---: |
| A compaction inbox admission | 17.1 ms | 8.1 ms |
| Total interval A held, from compact submission | 1485.7 ms | 1473.4 ms |
| Four info reads during hold | 3.0–7.0 ms | 2.0–4.5 ms |
| Four A get reads during hold | 3.3–5.4 ms | 3.2–5.7 ms |
| Four B get reads during hold | 2.6–4.6 ms | 3.3–5.7 ms |
| Four native activity reads during hold | 2.0–4.3 ms | 1.8–5.6 ms |
| B prompt admission while A held | 6.3 ms | 6.7 ms |
| B completion wait after releasing B, A still held | 21.0 ms | 9.9 ms |
| A completion after releasing A | 11.9 ms | 12.7 ms |
| Outline RPC, 128 × 256 KiB | 132.0 ms | 96.6 ms |
| Concurrent outline native-info requests | 7 | 7 |
| Maximum outline info-request latency | 12.4 ms | 10.5 ms |
In the final run, six info requests completed before the outline finished; a
seventh began near its end and completed afterward. These are native HTTP round
trips, not a main-thread deadline or renderer frame-rate measurement. The
working-source pruning bundle SHA-256 was
`3222a741d8707ac672c4b6e0c199993437372fbccabac769c7bbf3821e1a510a`.
Both runs observed one compaction started, 29 real compaction deltas, and one
compaction ended. A fast observer records `received - event.created` using
same-host wall clocks: held-compaction/both-held session-event maximums were
23 ms; summary-release maximum was 10 ms. No growing delivery backlog was
observed in those phases. This is **delivery-lag evidence**, not an inspection
of an internal queue, a losslessness guarantee, or absence of all backlog.
The seed/import phase had maximum event-delivery lag of 264/193 ms respectively.
It is kept separate: bulk import, local JSON encoding/GC and native work can
contribute, and this fixture does not attribute that delay to compaction.
## Commands and retained evidence
```powershell
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
$env:TMP=$env:TEMP
node scripts/test-compaction-isolation-native.mjs `
'C:\Users\Admin\AppData\Roaming\npm\node_modules\@opencode\cli\bin\opencode.exe'
node --check scripts/test-compaction-isolation-native.mjs
```
Final run: PASS, OpenCode 2.0.21, child PID 19312, private endpoint
`http://127.0.0.1:63236` (stopped afterward).
Evidence directories beneath
`C:\Users\Admin\AppData\Local\Temp\opencode\opencode\`:
- `compaction-isolation-qAF00E/result.json` and `server.log`
- `compaction-isolation-cb4Y79/result.json` and `server.log`
An earlier successful development run used a shorter hold. The first setup
attempt failed before creating a server because `--version` had already made
the private config directory; directory creation is now idempotent. Neither is
included in the measured table above.
## Original investigation limits
- A's outgoing summary request was only 24,557 bytes. The separate 32 MiB tool
corpus stresses outline reads, **not** summary-context assembly. This result
excludes neither large-context serialization/parsing nor tool/provider-specific
synchronous work during actual compaction.
- Manual summary compaction under a synthetic compatible provider was exercised,
not provider-native checkpoint APIs or automatic context-overflow recovery.
- No CodeNomad HTTP proxy, renderer, reconnection or remote TCP path is present.
Their independently reproduced defects must not be dismissed by this result.
- The current cooperating outline source was tested; this is not a native
baseline/patch A/B or a general CPU-throughput comparison.
No product correction followed from that small-context non-reproduction. The
requested bounded large-A qualification is recorded next; runtime/platform
qualification and exact CPU attribution remain separate.
## Follow-up: real 2/8/32 MiB A context
### Fixture changes and size validation
`--context-mib 2|8|32` imports native user/assistant-tool pairs into A, using
templates generated by real native tool execution. Each tool has a distinct
canary and 256 KiB of ASCII text; one small trailing turn is retained separately.
The fixture rereads native context and asserts the exact message/tool counts
and total stored output bytes before measuring anything.
In this stress mode only, automatic compaction is disabled, `keep.tokens=0` and
the synthetic model advertises a 16,777,216-token context limit. This avoids
testing a silently shortened request instead of the requested byte budget. It
is deliberately not a typical production model policy. The V2 compaction guide
documents the retained tail and model-budget shortening behavior.
An initial development check exposed exactly that distinction: without a small
trailing turn, the first 2 MiB request contained only 7 of 8 payload markers.
The fixture failed rather than reporting the stored size as the compacted size.
Putting the current turn after the large summarized prefix corrected the corpus;
all subsequent runs assert all markers **and every payload-fill byte** at the
actual provider boundary. There is no direct SQLite seeding or mutation.
| A tool payload | Native messages (pairs + tail) | Payload markers received | Provider JSON bytes | Provider messages |
| --- | ---: | ---: | ---: | ---: |
| 2 MiB (2,097,152 bytes) | 18 | 8/8 | 2,123,428 | 26 |
| 8 MiB (8,388,608 bytes) | 66 | 32/32 | 8,421,286 | 98 |
| 32 MiB (33,554,432 bytes) | 258 | 128/128 | 33,612,826 | 386 |
Provider message counts differ from stored native counts because lowering adds
separate assistant tool-call/tool-result messages and the summary instructions.
These are compaction request sizes, not the earlier separate outline corpus.
`--skip-outline` omits that separate 32 MiB import/RPC so the assembly matrix is
not confused with outline load. Allowed A sizes are capped at 32 MiB, sequential
runs create only one private native child/provider each, and no size escalation
or runtime installation was performed.
### Phase measurements and causal checks
Immediately upon compact submission, info and B-get probes start, together with
a B prompt/wait. Their start/finish times are retained, with explicit booleans
for completion before provider headers/body. The provider records its handler
start (headers observable), complete body receipt and JSON parse separately.
The output distinguishes submission-to-arrival from **admission response** to
arrival; neither is mislabeled completed compaction duration.
All three witness calls must be issued before A's provider handler begins.
Their issuance is a client-call timestamp, not an upstream route-entry trace.
B succeeds before releasing A in every run. An additional B prompt is then
tested during the provider hold, both sessions must be running simultaneously,
and A's completed context/ended event is required after release. No timing
threshold was introduced in product or fixture assertions.
Two sequential runs at each size (no simultaneous fixture launch):
| Size / run | Compact admission | Submission → headers / full body | Max info / B-get wholly before headers | Early B prompt admission | Early B wait | B done before headers? |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| 2 / `IO0hKV` | 6.4 ms | 31.2 / 33.9 ms | 2.5 / 24.6 ms | 26.4 ms | 27.2 ms | no |
| 8 / `mrXUsj` | 8.7 ms | 119.7 / 130.4 ms | 10.2 / 55.9 ms | 59.0 ms | 38.7 ms | yes |
| 32 / `I1Py1V` | 31.8 ms | 224.8 / 309.5 ms | 47.4 / 57.3 ms | 9.4 ms | 32.1 ms | yes |
| 2 / `gomUTk` | 8.5 ms | 56.2 / 66.1 ms | 3.5 / 36.9 ms | 48.7 ms | 57.1 ms | no |
| 8 / `ShUeBE` | 24.3 ms | 163.5 / 173.4 ms | 83.7 / 83.2 ms | 28.5 ms | 100.5 ms | yes |
| 32 / `jKWuew` | 45.8 ms | 290.9 / 332.7 ms | 83.2 / 82.3 ms | 11.3 ms | 52.7 ms | yes |
Requests *started* before headers can straddle arrival; their maxima were
84.7/84.3 ms for info/B-get in the final 32 MiB run. The table's wholly-before
filter excludes that fixture's provider body parsing from those latency values.
For the final 2/8/32 runs, admission-response → provider headers was
47.6 / 139.0 / 244.9 ms. Local provider JSON parsing was separately
2.7 / 7.3 / 29.9 ms; it is not native assembly CPU.
During the held-response phase of the matrix, native reads were generally a few
milliseconds (maximum 18.1 ms); B prompt admission was 6.1–10.0 ms. B completed
without releasing A at every size, then A completed 12.4–19.4 ms after release.
All runs observed started, 29 compaction deltas and ended, with no failed event.
Observed session-event lag in the assembly sampling phase reached 128 ms, versus
28 ms maximum during the B/provider-held phase. These are delivery observations,
not internal queue-depth or losslessness measurements.
### Attribution and limits
Confirmed: the pre-provider preparation/dispatch window grows with this stress
corpus, and unrelated native operations sometimes experience transient latency
inside that window. It cannot be attributed to the deliberately held summary
response: the provider hold has not begun for the wholly-before samples. B also
completes before A reaches the provider in the 8/32 MiB runs, ruling out a
continuous global stall throughout that preparation window in these executions.
Not confirmed: one particular synchronous SQLite step, tokenizer, JSON encoder,
lock or upstream function as the cause. The distribution differs by operation
and run, with fast info responses alongside slower B-get/admission in some
samples. Host scheduling, native GC/storage work and fixture scheduling remain
possible contributors. There is no native CPU profile or route-entry trace;
HTTP latency alone does not prove uninterrupted event-loop blocking. No upstream
or CodeNomad production patch is justified by this bounded evidence alone.
A compatibility smoke run with **2 MiB A plus the separate outline enabled**
also passed (`6OnLuq`). It verified all A bytes plus 13 completed native
heartbeats during a 218.2 ms outline RPC. That extra run is outside the matrix:
its pre-header B-get/prompt were 89.1/93.5 ms and seed-phase event lag reached
2.56 s. This additional variability reinforces the host/seed/GC qualification
limits; it must not be hidden or mislabeled a persistent compaction freeze.
The corpus is synthetic repeated ASCII, with imported historical templates,
fixture token usage and an artificially permissive model budget. It tests real
assembly of bounded bytes, not the reporter's exact token/JSON/provider workload,
automatic recovery, provider-native checkpoints or remote Linux/Windows HTTP.
### Reproduction / next step
```powershell
node scripts/test-compaction-isolation-native.mjs `
'C:\Users\Admin\AppData\Roaming\npm\node_modules\@opencode\cli\bin\opencode.exe' `
--context-mib 32 --skip-outline
```
Use `2` and `8` for the other bounded sizes; retain the TEMP/TMP setup above.
All seven successful runs retain `result.json` and `server.log` under the same
private `compaction-isolation-<run>` namespace. Every child was stopped and its
exit awaited; the script naturally exited. `node --check` passes. Only this
script and this report were edited for the follow-up.
Smallest useful next step: profile the **native pre-provider preparation window**
and correlate native route-entry/exit and event-loop work under a controlled
host, at the same fixed byte budgets. Preserve the verified transmitted bytes
and the wholly-before-header filter. Do not patch an unproven upstream hotspot
or reinterpret provider waiting as freeze; runtime 2.0.19 and the real remote
platform remain separate, untested qualifications.

View file

@ -0,0 +1,85 @@
# Issue #824 — independent outline-reader review
Baseline reviewed: `366830f6`. Scope: the current `outline-index.ts` change,
its responsiveness regression test and profiler, and the related performance
claims. No product code was changed during this review.
## Verdict
Approved with no blocking finding. The patch addresses the demonstrated
event-loop starvation while preserving the outline protocol and its existing
correctness fences. The documentation correctly limits the result to improved
cooperation: it does not claim lower total CPU, a hard 8 ms deadline, or that
this defect alone explains issue #824.
## Correctness review
- `.all()` was replaced by iteration over the same header statement; the SQL,
parameters, ordering and 512-row limit are unchanged. Projection likewise
retains its existing SQL and response mapping.
- A direct baseline/current comparison produced byte-equivalent JSON results,
including checkpoint digests, for an initial mixed outline, a persisted-known
delta after delete/update/append, and an ownership/revert-bounded read.
- Existing navigation coverage still verifies the 16,384-entry page bound,
cursor snapshot (`through`) behavior, deletion holes, tail checkpoint growth,
pruning detection, tool metadata, ownership and staged-revert visibility.
- The read transaction still begins before ownership and bounds are read and is
held across all cooperative yields, so a page observes one snapshot. An abort
from either new loop rejects through `yieldTurn`; iterator cleanup is followed
by the existing unconditional `ROLLBACK`. Both new tests observe
`db.isTransaction === false` afterward.
- Each RPC obtains its own read-only `DatabaseSync` connection in
`withHistoryDatabase`. Yielding with this transaction open therefore does not
expose a shared connection to another RPC. It can retain a read snapshot for
longer in wall-clock time, but work remains bounded and promptly cancellable.
- `EXPLAIN QUERY PLAN` for the header query, both with and without the revert
`id < ?` predicate, uses
`session_message_session_seq_idx (session_id=? AND seq>? AND seq<?)`. The
patch does not change query plans or introduce a sort scan.
## Responsiveness evidence
The regression design is discriminating rather than timing-sensitive. Its real
SQLite view charges deterministic synthetic time whenever payload data is
accessed, while `setImmediate` supplies an actual pending cancellation.
- Current code: both header and projection cancellation cases passed.
- The same test copied outside the worktree with only `outline-index.ts`
replaced by the exact `366830f6` source failed both cases: the header case
consumed 896 expensive payload accesses before cancellation ran; the
projection case consumed 384 after its 512 free header accesses.
- A smaller independent profile (`128` rows × `256 KiB`) observed current
maximum turn gaps of `8/8/8 ms`, versus baseline `40/43/38 ms`. Total scan
times were comparable (`43–45 ms` current, `39–43 ms` baseline), supporting
the documented cooperation claim without implying throughput improvement.
- One SQLite iterator step remains synchronous. A single exceptionally costly
row can exceed the 8 ms budget before JavaScript regains control; this
limitation is explicitly documented in both code and investigation notes.
## Validation performed
```text
node --import tsx --test \
packages/server/src/opencode/session-pruning/outline-responsiveness.test.ts \
packages/server/src/opencode/session-pruning/navigation.test.ts
=> 11 passed, 0 failed
npm run typecheck --workspace @neuralnomads/codenomad
=> passed
git diff --check 366830f6 -- \
packages/server/src/opencode/session-pruning/outline-index.ts
=> passed (only Git's existing LF/CRLF working-copy warning)
```
The coordinator's full isolated OpenCode 2.0.21 fixture result was not rerun in
this read-only review; the focused native-SQL and contract checks above are
independent of that reported qualification.
## Residual risk / next step
No adjustment is required for this patch. Retain the explicit limitation that
cooperative iteration cannot preempt one SQLite step. If real-session evidence
later shows individual JSON projection steps exceeding the acceptable latency,
the smallest follow-up is architectural (worker-thread/off-loop projection or a
less parse-intensive stored index), not a tighter JavaScript timer threshold.

View file

@ -0,0 +1,160 @@
# #824 — récupération du correctif de rendu des tâches imbriquées
2026-10-01, worktree `issue-824-performance`, HEAD `366830f6`.
## Résultat et portée
Le correctif minimal de `task.tsx` est maintenant présent dans le worktree mission,
sans commit/push ni déploiement. Deux régressions rouges sont reproduites sur la
source baseline ; la correction passe les **63 scénarios** des quatre suites
navigateur ciblées, naturellement, sans force-exit, skip ou force-click ajouté.
La mesure A/B finale, 30 échantillons par variante, observe une médiane de temps
main-thread **353,39 -> 22,72 ms** par delta dans le fixture, pas dans l'application
desktop. Le contrôle instrumenté confirme **80 retraits + 80 insertions -> zéro**,
avec **80 snapshots conservés**. Ce patch n'élimine pas les relectures natives.
Seuls ces six fichiers appartiennent à cette tâche :
| Fichier | Delta / taille |
| --- | --- |
| `packages/ui/src/components/tool-call/renderers/task.tsx` | +29/-18, 647 lignes |
| `packages/ui/tests/browser/render-cost.test.ts` | nouveau, 245 lignes |
| `packages/ui/tests/browser/fixtures/render-cost.tsx` | nouveau, 150 lignes |
| `packages/ui/tests/browser/fixtures/render-cost-timeline.tsx` | nouveau, 44 lignes |
| `scripts/test-render-cost-audit.mjs` | nouveau, 57 lignes |
| `dev-docs/issue-824-render-recovery.md` | ce rapport |
Aucun changement de cette tâche dans les stores, `server-events`, `opencode-service`,
styles, dépendances ou serveur. Les modifications concurrentes de ces surfaces dans
le worktree commun ne sont pas notre livraison. Aucun daemon partagé, profil,
desktop, réseau utilisateur ou base utilisateur utilisé ; aucune dépendance
réinstallée/symlinkée. Les HTTP fixtures sont loopback et bloquent les autres origines.
## Provenance et logique
Source : `audit-resume-integration`, correction du précédent audit de rendu.
Le blob baseline de `task.tsx` est identique à `47f9e43e` et `366830f6` :
`dee90df8560b83958dbf6d6f7c40e013bbdb289f`.
Le postimage produit canonique Git est identique au correctif audit :
`9a427900afcad8f274db5d9a77600836f24a728e`.
Les SHA bruts diffèrent pour ce seul fichier à cause des fins de ligne du checkout ;
comparaison LF-normalisée et `git diff --no-index` : aucun delta de contenu.
Les deux fixtures et le runner A/B sont repris byte-for-byte de l'audit. Le test
était aussi identique avant une adaptation de harness : chaque serveur utilise
désormais un cache Vite temporaire unique, supprimé après fermeture. Cela sépare
ses modules optimisés des autres fixtures concurrents, sans changer les assertions.
La correction conserve les clés affichées pendant une relecture autoritaire,
réindexe dès son retour, et maintient une shell par identité de tool avec getters
réactifs. Un changement de clé remonte bien une shell neuve. Une page autoritaire
vide supprime les étapes ; une erreur de lecture conserve le dernier affichage et
le retry explicite. Markdown utilise son hash existant plutôt qu'une révision
normalisée à zéro ; la révision de part reste suivie pour les updates in-place.
## Reproductions fonctionnelles exécutées ici
Node `v25.2.1`, Windows, Chromium Playwright `153.0.8010.12`, Vite development.
Contrôle rouge par override Vite de la source exacte baseline, sans réécrire le
renderer du checkout : deux assertions échouent avec messages attendus, pas timeout :
1. `authoritative reprojection must not recreate the tool shell` ;
2. `the native record changed but the displayed Markdown is stale`.
Le sample instrumenté baseline passe son contrôle de 80 étapes et compte 80 ajouts,
80 retraits et 80 clones à chacun des dix deltas. La commande rouge a donc un exit 1
attendu (2 FAIL + 1 PASS) ; le wrapper de collecte garde cet exit dans le log.
La suite verte finale passe **63/63**, zéro skip/failure, exit 0 naturel (~202 s) :
render-cost (8), session-rendering (46), task-copy (6), tool-images (3). Elle exerce
les vrais composants Solid, stores et dispatcher SSE, avec seulement le transport
natif et les réponses API synthétiques. Le maintien du scroller pendant MMB, du
scrollTop et disclosure, les copies exactes, l'update in-place, l'erreur/retry,
la suppression et le changement de clé sont réellement vérifiés.
`npm run typecheck --workspace @codenomad/ui` passe, y compris au dernier replay.
`git diff --check` des fichiers de livraison passe.
Le test est admis par le glob existant `tests/browser/*.test.ts` du script package ;
aucune modification de gate/manifest n'est nécessaire.
## A/B CPU actuel — séparé des résultats historiques
Protocole inchangé : 80 étapes read terminées × 8 000 caractères, collapsed ;
3 warmup deltas puis 10 deltas isolés dans chacun des trois couples baseline/fixed.
Processus/pages frais, alternance baseline puis fixed ; compteur clone et observer
DOM **désactivés** pour les timings. CDP cumule les durées jusqu'après hydration et
deux animation frames. Les coûts sync seuls n'incluent pas le travail différé.
| ms/delta, médiane [min–max], écart-type échantillon | Baseline | Correction |
| --- | --- | --- |
| main-thread tasks | 353,39 [189,76–737,96], 141,77 | 22,72 [11,00–68,03], 12,39 |
| script | 18,65 [14,75–57,85], 11,10 | 0,92 [0,29–40,30], 10,04 |
| layout | 3,58 [2,93–5,39], 0,63 | 0,14 [0,09–0,36], 0,05 |
| recalcul style | 11,69 [10,39–14,35], 0,94 | 0,20 [0,11–0,32], 0,06 |
| dispatcher sync | 17,25 [11,20–33,60], 4,88 | 0,25 [0,10–0,50], 0,11 |
L'audit antérieur rapportait 369,98 -> 20,64 ms sur 30 échantillons par variante.
Ce ne sont pas les échantillons actuels et ils ne sont pas fusionnés avec eux.
Le premier A/B actuel était 353,90 -> 17,03 ms ; le replay suivant 378,59 -> 15,61 ms.
Ces variations ne sont pas une garantie de latence/FPS et aucun seuil CI absolu
n'est ajouté.
## Tentatives et limites conservées
Deux reprises A/B supplémentaires ont échoué avant le montage du fixture fixed,
sur `page.waitForFunction(window.fixture)` après 30 s. Elles restent dans
`codenomad-render-cost-lehbGS/3-fixed.log` et `codenomad-render-cost-FN0wKE/1-fixed.log`
sous le temp approuvé. Pas de gain CPU calculé depuis ces lots incomplets.
Leurs erreurs sont de démarrage du harness, pas une assertion renderer. La
collision du cache Vite partagé est une **hypothèse**, pas une cause démontrée :
après isolation du cache, les six invocations A/B et 63 tests passent, mais il n'y
a pas eu de contrôle négatif causal du cache en concurrence.
Des snapshots SHA avant/après des sources ont aussi détecté des mutations de tâches
concurrentes (stores compaction, event handlers, serveur et ressources générées).
Les cinq fichiers exécutables de notre livraison sont restés inchangés pendant
le dernier A/B + suite verte. Ces runs **ne sont pas une qualification d'une
composition intégrée entièrement immuable** : refaire le replay indépendant sur
une composition figée après intégration des autres correctifs. Les wrappers
marquent cette dérive par un exit 1, distinct des enfants A/B et tests, tous exit 0.
Pas de qualification production-build, Electron/WebView2, Linux/macOS, heap,
GPU/FPS, backend réel, compactage natif ou freeze applicatif universel. Le coût des
clones/relectures demeure. `task.tsx` reste **647 lignes**, au-dessus du seuil de
signalement ~500 ; aucun refactor de taille seul n'a été introduit.
## Artefacts et commandes
Tous les chemins ci-dessous sont sous
`C:/Users/Admin/AppData/Local/Temp/opencode/` :
- `issue-824-render-isolated-xn3MnK/red-final.log`
SHA256 `947bb2f6beae52db032288e8f855af27fcaa7deb90a3787a85fdf49f82a06c18`.
- `issue-824-render-isolated-xn3MnK/green.log`
SHA256 `15b7d31be5b99cbc8e3bf62f322fa5b14a1f710e740a5cd0b2a8e0d4c718a7bd`.
- `issue-824-render-isolated-xn3MnK/{before,after,result}.json` : dérive de composition.
- `codenomad-render-cost-dD0phn/metrics.json` et six logs adjacents : A/B final.
SHA256 metrics `f1a2a4a7ce5ca6f042328bc5d8e84fc315be49c90a5f26a67759859d3e801ba8`.
SHA256 produit brut livré : `task.tsx` =
`d130c5fb2219772c5de0c63393744783cdb659734024f88cbe73cafeec818100`.
SHA256 test final = `4abe4345a3a5f1a00d0e94544fd02b70851d07efa6b294a55466bc51ef7d2fde`.
```powershell
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
$env:TMP=$env:TEMP
# Dans packages/ui, override baseline pour les assertions rouges uniquement :
$env:CODENOMAD_RENDER_TASK_BASELINE='<temp>/task-baseline.tsx'
node --import tsx --test --test-concurrency=1 --test-name-pattern='^(unrelated child text|native page output|audit sample:)' tests/browser/render-cost.test.ts
Remove-Item Env:CODENOMAD_RENDER_TASK_BASELINE
node --import tsx --test --test-concurrency=1 tests/browser/render-cost.test.ts tests/browser/session-rendering.test.ts tests/browser/task-copy.test.ts tests/browser/tool-images.test.ts
# À la racine :
node scripts/test-render-cost-audit.mjs 3
npm run typecheck --workspace @codenomad/ui
```
Prochaine étape minimale : revue indépendante des six fichiers de cette livraison,
puis replay des deux assertions rouges, 63 contrôles verts et A/B sur une composition
figée. Aucun restart/déploiement ni élargissement du patch n'est nécessaire.

View file

@ -0,0 +1,137 @@
# Issue #824 — revue indépendante auth et admission HTTP
## Verdict
**Gates auth et admission HTTP : acceptés, sans finding bloquant.**
La revue porte sur le diff intégré non commité de
`D:\CodeNomad\.codenomad\worktrees\issue-824-performance` depuis `366830f6`,
et non sur les worktrees sources. Les trois fichiers produit transport intégrés
ont exactement les mêmes blobs Git que `d9598e42`. Les changements UI, relais
d'événements, outline et rendu présents simultanément dans le worktree sont hors
périmètre.
Ces corrections ont une causalité plus étroite que l'intitulé général #824 :
- l'auth retire les dérivations scrypt HTTP du thread principal Node ;
- l'admission HTTP retire un observateur local abandonné pendant ses préflights
et empêche ses continuations tardives ;
- elles ne démontrent ni la cause du blocage natif pendant une vraie compaction,
ni une saturation HTTP/1 Electron Linux → backend Windows, ni l'élimination du
freeze global rapporté.
Les deux rapports d'intégration conservent correctement cette limite et ne
promettent pas une correction universelle.
## Findings prioritaires
### Aucun finding P0/P1/P2
Je n'ai trouvé ni autorisation accordée sur un ancien mot de passe, ni replay de
mutation, ni invalidation de connexion partagée sur abandon local, ni forwarding
après annulation de préflight dans le périmètre revu.
### Risques résiduels non bloquants
1. **Saturation du pool libuv non qualifiée.**
`packages/server/src/auth/password-hash.ts:58-64` rend le thread HTTP
coopératif via `crypto.scrypt`, mais ne supprime pas le coût CPU/mémoire et ne
borne pas une rafale de logins. Il n'existait pas de rate limiter sur ces
routes dans la baseline et le patch n'en retire aucun. Une politique
d'admission serait un changement séparé, non requis pour accepter ce patch.
2. **Persistance auth toujours synchrone.**
`packages/server/src/auth/auth-store.ts:176-184` conserve les petites
écritures synchrones existantes. Les mesures et le correctif concernent la
dérivation scrypt, pas une garantie d'absence totale de pause filesystem.
3. **Qualification réseau/OS encore absente.**
Les tests transport utilisent de vrais sockets loopback et le client SDK,
mais pas Electron Linux vers Windows, suspension/relogin, proxy intermédiaire
ou limite de connexions Chromium. Le correctif prouve la fuite d'observateur
et ses fences, pas le scénario utilisateur complet.
## Revue auth
- `packages/server/src/auth/password-hash.ts:23-55` conserve le record v1, le
sel 16 octets, la clé 64 octets, les paramètres enregistrés et
`crypto.timingSafeEqual`. Le seul chemin synchrone restant est explicitement
`hashPasswordSync`, appelé pendant l'initialisation CLI/env avant service.
- `packages/server/src/server/routes/auth.ts:101,150` attend les résultats login
et password ; la recherche dépôt ne trouve pas d'autre caller produit oublié
de `validateLogin`, `validateCredentials`, `setPassword` ou `hashPassword`.
- `packages/server/src/auth/auth-store.ts:117-160` sérialise les changements par
ordre d'admission. La queue absorbe un rejet uniquement pour permettre le
changement suivant, tandis que le caller original reçoit bien l'erreur. Une
persistence bootstrap échouée ne supprime pas `bootstrapUsername` et ne publie
pas de nouveau cache.
- `packages/server/src/auth/auth-store.ts:101-115` relit l'identité du record
cached après l'await scrypt. Un login dérivé sur l'ancien record est refusé si
une mutation a persisté entre-temps, sans recalcul ni replay.
- Les réponses 401/409, le cookie, le statut bootstrap, l'override CLI/env et
l'auth désactivée sont couverts. Aucun mécanisme de rate-limit existant n'est
modifié.
## Revue admission HTTP
- `packages/server/src/server/request-admission.ts:8-14` observe dès l'entrée
l'upload `aborted` et le `close` d'une réponse incomplète. Il n'interprète pas
le `close` normal de l'IncomingMessage comme une déconnexion.
- `packages/server/src/server/request-admission.ts:16-36` fait courir uniquement
l'attente locale contre l'abandon. La Promise partagée reste observée ; sa
génération n'est ni annulée ni invalidée et un rejet tardif ne devient pas une
unhandled rejection.
- `packages/server/src/server/http-server.ts:699-975` transmet le signal aux
lectures natives qui le supportent (`profile`, session/project, PTY/Shell,
ownership/location) et entoure les acquisitions/inventaires non annulables par
`wait()`. Chaque frontière résolue revérifie l'abandon avant de poursuivre.
- Les guards précédant la fence de mutation, l'environnement et le forwarding
sont présents à `http-server.ts:975`, `989-1015` et `1022-1024`. Le `finally`
de l'entrée dispose les listeners sur toutes les sorties.
- `packages/server/src/opencode/compatibility/proxy.ts:27-40` installe le listener
de forwarding avant le fetch puis refuse immédiatement un downstream déjà
fermé. Après admission native, la sémantique antérieure demeure : abandon de
la réponse HTTP sans replay ni invalidation de la connexion partagée.
- `packages/server/src/workspaces/manager.ts:270-286` propage le signal à la
validation native de location. Une annulation éventuellement convertie en
`false` par son `catch` est réémise par le `wait()` appelant, dont le signal est
alors aborté ; elle ne devient donc pas une autorisation ou un 403 tardif.
## Validation fraîche
Exécutée depuis le worktree intégré, Windows, Node 25.2.1, sans daemon/profil/base
utilisateur, restart ou déploiement :
```powershell
node --unhandled-rejections=strict --import tsx --test packages/server/src/auth/password-hash.test.ts packages/server/src/server/routes/auth-responsiveness.test.ts packages/server/src/server/routes/auth.test.ts
node --unhandled-rejections=strict --import tsx --test packages/server/src/server/__tests__/instance-proxy-disconnect-diagnostic.test.ts packages/server/src/server/request-admission.test.ts packages/server/src/server/__tests__/instance-proxy.test.ts packages/server/src/server/__tests__/instance-proxy-legacy.test.ts packages/server/src/opencode/compatibility/proxy.test.ts
npm run typecheck --workspace @neuralnomads/codenomad
$env:TEMP='C:\Users\Admin\AppData\Local\Temp\opencode'
$env:TMP=$env:TEMP
node --unhandled-rejections=strict --import tsx --test "packages/server/src/**/*.test.ts"
```
Résultats :
- auth ciblé : **9/9 pass**, sortie naturelle ;
- transport ciblé : **65/65 pass**, sortie naturelle ;
- typecheck serveur : **pass** ;
- gate serveur complet intégré : **799 tests, 793 pass, 0 fail, 6 skips
déclarés**, sortie naturelle en environ 35,7 s ;
- `git diff --check` ciblé : aucune erreur whitespace (les commandes
`--no-index` retournent normalement 1 parce que les nouveaux fichiers
diffèrent de `NUL`; seulement les avertissements LF/CRLF habituels).
Le gate complet contient aussi les autres changements concurrents déjà intégrés ;
il confirme leur composition actuelle avec ces patches, sans attribuer leurs
tests aux deux corrections revues.
## Taille et suite minimale
Les fichiers touchés déjà surdimensionnés restent
`packages/server/src/server/http-server.ts` (~2346 lignes) et
`packages/server/src/workspaces/manager.ts` (~1240 lignes). Aucun refactor de
taille n'est justifié dans cette correction bornée.
Suite minimale recommandée : conserver les patches tels quels et poursuivre
séparément la qualification avec runtime privé d'une vraie compaction et le
scénario Electron Linux → Windows. Ne pas présenter ces deux corrections comme
preuve que la cause native ou le freeze global #824 est résolu.

View file

@ -0,0 +1,119 @@
# #824 — correction M1 de membership des étapes imbriquées
## Résultat
Le finding M1 de `issue-824-ui-review.md` est corrigé dans le worktree mission.
Les cinq nouvelles régressions échouent sur le renderer livré avant cette tâche,
puis **68/68 tests navigateur** passent sur le renderer corrigé, avec rejets stricts,
sortie naturelle, aucun skip/force-exit/force-click. Les shells conservés gardent
leur disclosure, leur scroller et leur scrollTop après suppression ou déplacement.
Le contrôle instrumenté de 80 étapes conserve **zéro retrait/insertion par delta**.
Cette tâche modifie uniquement :
- `packages/ui/src/components/tool-call/renderers/task.tsx` ;
- `packages/ui/tests/browser/render-cost.test.ts` ;
- `packages/ui/tests/browser/fixtures/render-cost.tsx` ;
- ce rapport `dev-docs/issue-824-task-membership-fix.md`.
Aucun store/SDK/serveur/style, daemon, base utilisateur, profil, déploiement ou
restart modifié. Aucun commit/push, sous-agent ni installation de dépendances.
## Cause et correction
L'index incrémental inférait les changements de membership depuis les nombres de
messages/parts et l'identité du message de queue. Une relecture autoritaire peut
conserver toutes ces propriétés et supprimer, remplacer, réordonner ou changer le
type d'une part. Les shells supprimés se démontaient, mais `childToolKeys` et le
compteur/troncature gardaient des clés fantômes. Un ajout retrouvant l'ancienne
cardinalité restait invisible.
Le renderer réutilise maintenant son **scanner structurel borné existant** au point
de révision de session. Il ne compare ni copie les payloads/output : seuls les IDs
et le type de part déterminent les clés. On supprime les heuristiques de counts/tail
plutôt que d'ajouter un autre cache ou une politique de refresh. La liste précédente
est rendue telle quelle quand ses clés ordonnées sont identiques.
Le scan reste limité à **10 000 unités message/part** et s'arrête une fois trouvées
201 étapes ; seules les **200 dernières** s'affichent. Les constantes et le budget
du scanner restent inchangés. Le coût des IDs/type est payé à chaque révision,
même si le texte seul change : c'est le compromis explicite pour reconnaître tout
remplacement autoritaire, sans observer/comparer les gros payloads. Aucun gain CPU
nouveau ni coût maximal de 10 000 IDs en production n'est revendiqué ici.
`For` remplace `Index` sur les clés tool : une part retenue suit son identité quand
une suppression ou un réordonnancement change sa position. Une identité nouvelle
reçoit une shell/disclosure neuve. Les getters réactifs et le hash Markdown de la
correction précédente restent inchangés. Une lecture pendante conserve la liste
résidente ; le retour autoritaire, une page vide et le changement de child-session
continuent à la réconcilier. Le recalcul remet aussi la troncature à jour après shrink.
Delta produit relatif au renderer **déjà intégré avant cette tâche**, préimage
conservée dans le temp : **+12/-90 lignes**. Ce n'est pas un delta contre HEAD, qui
ne contient pas encore les correctifs mission. `task.tsx` reste **569 lignes**, au-
dessus du seuil d'avertissement ~500 ; aucun refactor de taille indépendant.
## Preuves exécutées
Node `v25.2.1`, Windows, Chromium Playwright `153.0.8010.12` ; vrais composants
Solid, stores et `loadMessages(..., { force: true })`, native/API réponses privées,
réseau hors loopback bloqué. Le message natif `child-message` reste le même ; le
fixture remplace sa liste de parts, pas le composant ni l'index.
Les cinq nouvelles assertions rouges causales, sans seuil timing :
1. `2 -> 1 -> 2` en gardant step-0 : counter reste `2 steps` au lieu de `1 steps` ;
2. même scénario en gardant step-1 (suppression en tête) : même counter faux ;
3. remplacement/réordre `[step-0, step-1] -> [step-2, step-0]` à cardinalité égale :
nouvelle step-2 absente ;
4. changement tool -> text avec ID inchangé : counter reste à 2 au lieu de 1 ;
5. shrink de 201 à 199 parts : counter reste `200+ steps` au lieu de `199 steps`.
Sur correction, tous passent. Les deux premiers vérifient également l'arrivée
exactement une fois de la nouvelle step-2, puis l'identité shell/scroller, disclosure
ouverte et scrollTop 240 de l'étape retenue. Le troisième vérifie ordre DOM exact et
shell retained. Le cinquième vérifie 200 shells initiales, 199 ensuite et disparition
du diagnostic de troncature.
Gate final : 13 render-cost + 46 session-rendering + 6 task-copy + 3 tool-images =
**68 scénarios distincts**, ~176 s, exit 0 naturel, aucun failure/skip. Le contrôle
perf réel compte, pour chacun de dix deltas, **80 clones, 0 added, 0 removed** ; une
assertion explicite interdit maintenant les remounts dans ce sample instrumenté.
Ces compteurs sont distincts des A/B CPU antérieurs, qui restent dans leurs rapports
et ne sont pas recyclés comme mesures de ce patch.
`npm run typecheck --workspace @codenomad/ui` passe sur dernier patch.
`git diff --check` ciblé passe (avertissement LF/CRLF seulement).
Les SHA des trois exécutables de livraison sont identiques avant/après la séquence
rouge/verte. Cela qualifie la stabilité de ce lot, pas une composition entière
immutable de tous les travaux de mission.
## Artefacts et replay
Sous `C:/Users/Admin/AppData/Local/Temp/opencode/` :
`issue-824-membership-c5bca1d90b4a4564bd4c7f695b6381bd/` contient :
- `task-before.tsx` : préimage exacte avant correction M1 ;
- `red.log` : 5 échecs d'assertion attendus, exit 1, naturel ;
SHA256 `db9316eb888984364cbdd59d2fac8fac74600ed9e5e779f9c86b33869b1fc51a` ;
- `green.log` : 68 passes, exit 0, naturel ;
SHA256 `e4f5a996b9b576703888b353e14c02940b4dd72d0fe3e056cc54b96fcffe1849` ;
- `result.json` : statuts enfants + empreintes avant/après, `changed: []`.
SHA256 produit livré :
`f07ce171a6b1d3394ce94be496b0abd67f8bc0bb48e946eb9173a681c844f401`.
SHA256 test : `93d9e95988b53eb1e3934ecd13f4e8d32e0eb797682a866db113797174be5075`.
SHA256 fixture : `0df543ea983c23e33a4957ed6732dc81a2e04cf5ede7d4f1281d6ee058360ebb`.
Depuis `packages/ui`, avec TEMP/TMP pointant vers le temp approuvé :
```powershell
$env:CODENOMAD_RENDER_TASK_BASELINE='<artefact>/task-before.tsx'
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 --test-name-pattern='^authoritative task membership' tests/browser/render-cost.test.ts
Remove-Item Env:CODENOMAD_RENDER_TASK_BASELINE
node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 tests/browser/render-cost.test.ts tests/browser/session-rendering.test.ts tests/browser/task-copy.test.ts tests/browser/tool-images.test.ts
```
Prochaine étape : re-review indépendante du finding M1, puis validation combinée
sur la composition figée. Pas de qualification desktop/Linux/macOS, daemon réel ou
disparition universelle des freezes revendiquée par ce lot.

View file

@ -0,0 +1,218 @@
# Issue 824 — revue indépendante de l'intégration UI
## Verdict
**Corrections demandées avant validation finale**, pour un défaut de membership
du renderer `task` confirmé ci-dessous. Le coalescing du compactage et la
séparation des diagnostics SSE sont acceptables dans leur périmètre; aucun défaut
de perte de texte, d'ordre ou de fence n'a été reproduit sur ces surfaces.
Revue bornée au diff depuis `366830f6` de `opencode-data.ts`, `instances.ts`,
`compaction-delta-buffer.ts`, `task.tsx`, `server-events.ts`,
`event-source-handlers.ts` et de leurs fixtures. Aucun fichier produit n'a été
modifié par cette revue.
## Findings
### M1 — une suppression autoritaire d'une part tool laisse une clé fantôme dans le renderer `task`
Dans `task.tsx`, la mise à jour incrémentale ne traite que
`nextPartCount > previousPartCount`. Si une page autoritaire conserve le message
mais retire une part tool, `nextPartCount < previousPartCount` ne déclenche ni
rescan ni retrait de `childToolKeys`.
La ligne supprimée disparaît malgré tout parce que `TaskToolCallRow` ne trouve
plus sa part et son `Show` se démonte. En revanche, la clé reste dans
`childToolKeys` : le compteur et la troncature sont faux, et un ajout ultérieur
qui ramène le nombre de parts à sa valeur précédente peut ne pas être indexé.
Reproduction indépendante avec le vrai composant, le vrai store et
`loadMessages(..., { force: true })`, via une transformation Vite en mémoire qui
retire seulement `step-0` de la réponse native :
```json
{"count":"2 steps","visible":1,"partIds":["step-1"]}
```
La reproduction directe déjà exposée par le fixture donne le même état. Le test
vert actuel `removeTool(0)` affirme uniquement que le DOM de `step-0` est détaché;
il n'affirme ni `1 step`, ni la membership, ni l'ajout suivant. Le test de page
vide couvre une baisse du nombre de messages et prend une autre branche de full
rescan.
Correction minimale recommandée : full rescan borné dès qu'un nombre de parts
d'un message indexé diminue (et, si les pages peuvent remplacer des identités à
cardinalité égale, comparer aussi l'identité bornée des parts). Ajouter un cas
page autoritaire 2→1 dans le même message, puis ajout d'une nouvelle part 1→2;
le shell de la part non supprimée doit rester monté, le compteur doit faire
2→1→2 et la nouvelle clé doit apparaître exactement une fois.
### R1 — l'accumulateur de compactage est borné dans le temps, pas en taille
Le timer part du premier fragment et n'est jamais repoussé : avec de vrais
événements EventSource échelonnés, la résidence est donc bornée à environ 250 ms.
En revanche, tant que la boucle ne rend pas la main, le nombre de fragments et la
taille de la chaîne concaténée n'ont pas de plafond explicite. Le gros backlog
synchrone du diagnostic peut toujours construire un unique payload volumineux.
Ce n'est pas bloquant pour le correctif revendiqué : le transport produit livre
ici un événement par callback, les nouveaux tests de cadence rendent réellement
la main, et le résumé final doit de toute façon exister en mémoire. Ne pas appeler
le buffer « borné en mémoire ». Si une mesure réelle montre une rafale sans
yield, ajouter une limite qui force une réduction intermédiaire sans supprimer ni
réordonner de texte; ne pas introduire un drop silencieux.
### T1 — le test store compactage ne fournit pas une sortie naturelle
Les 11 assertions passent avec rejets stricts, mais le processus reste vivant
au-delà de 120 s sans `--test-force-exit`. Avec l'option de force, 11/11 passent
en 0,77 s. Cela correspond à la convention déjà déclarée pour les stores importés
et ne démontre pas une fuite du nouveau buffer, mais ce fichier seul ne constitue
pas un gate de lifecycle naturel. Les 10 tests navigateur intégrés ci-dessous
sortent naturellement.
## Contrats compactage vérifiés
- Chaque delta avance immédiatement `messageRevisions`, `fullDataRevisions` et
la fence de lecture du reducer observé; l'agrégat réentrant n'avance pas ces
révisions une seconde fois.
- Un buffer est séparé par couple instance/session. Un événement non-delta de la
même session force l'ordre; les autres sessions et instances ne forcent pas le
flush.
- `ended` et `failed` annulent les fragments car le SDK 2.0.21 remplace le résumé
par `ended.data.text` ou remplace la ligne par l'erreur. Delete, reconnect,
prune, revert committed et dispose annulent aussi avant qu'un timer puisse
ressusciter le contenu.
- Une lecture SDK déclenchée avant admission est refusée par révision. Une lecture
après admission force d'abord l'agrégat; la page native qui contient déjà ce
texte n'est pas réappendue.
- Rotation à 200 messages, idle, nouvelle exécution et second start conservent
l'ordre. La queue de rotation et la resynchronisation autoritaire restent les
fences existantes.
- Une session inactive jamais chargée ne crée pas de transcript ni de payload de
reducer; seules les fences scalaires et le marqueur de compaction inachevée
persistent. End/failed/dispose les retirent. Une activation intermédiaire passe
par la page autoritaire, conserve la fenêtre UI de 200 messages et reprend
ensuite le streaming sans duplication.
- Une session chargée puis inactive réduit l'agrégat mais n'hydrate pas sa vue
cachée; son retour recharge le texte exact.
Les cinq scénarios navigateur utilisent le vrai `SessionView`, le dispatcher
natif et les stores. Le cas cadencé livre huit événements dans huit tâches
séparées par 10 ms; ce n'est pas uniquement le backlog synchrone historique.
## Renderer imbriqué : propriétés validées hors M1
- Les getters réactifs mettent à jour output, copy et erreurs sans remonter le
shell d'une clé stable; disclosure, scroller et scrollTop restent conservés.
- Le `Show keyed` externe remonte le shell quand `Index` réutilise une position
pour une autre clé, donc l'état d'une étape évincée n'est pas transféré.
- Une invalidation de lecture conserve le snapshot résident; le full rescan après
retour autoritaire et la page vide suppriment les messages obsolètes.
- La limite de scan reste 10 000 unités et l'affichage 200 étapes. Le patch ne
rend pas le scan ou le nombre de shells non borné.
- L'output d'une page native à révision de part zéro utilise de nouveau son hash
Markdown et n'affiche pas le cache de l'ancienne page.
## Diagnostic SSE / flush Solid
La correction ne prétend plus capturer une dérivation Solid dans le `try/catch`
de chaque abonné. Elle sépare correctement les frontières : JSON invalide produit
`Failed to parse event`; une exception qui sort du flush de `solidBatch` produit
`Failed to dispatch event`. Elle ne déclenche ni reconnexion ni rejeu, et les
événements suivants continuent en FIFO. Le test navigateur dérivé traverse le
vrai EventSource et confirme les deux événements chez les abonnés natif et typé.
Les risques déjà acceptés restent inchangés : ce logger `debug` est désactivé par
défaut, et la course serveur abort local / erreur amont n'est pas couverte ici.
Aucune nouvelle promesse de diagnostic persistant ou d'isolation du flush n'est
faite.
## Gates exécutés
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 packages/ui/tests/browser/compaction-responsiveness.test.ts packages/ui/tests/browser/event-subscriber-isolation.test.ts` — **10/10**, sortie naturelle.
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 --test-name-pattern="^(unrelated child text|a failed child refresh|native page output|changing an indexed task key)" packages/ui/tests/browser/render-cost.test.ts` — **4/4**, sortie naturelle.
- `node --conditions=browser --unhandled-rejections=strict --import tsx --test packages/ui/src/lib/event-source-handlers.test.ts` — **4/4**, sortie naturelle.
- `node --conditions=browser --unhandled-rejections=strict --import tsx --test packages/ui/src/stores/opencode-compaction.test.ts` — **11 assertions vertes**, mais timeout de lifecycle à 120 s.
- Même test avec `--test-force-exit` — **11/11**, 0 échec.
- `npm run typecheck --workspace @codenomad/ui` — vert.
- `git diff --check` ciblé — vert, hors avertissements LF/CRLF du worktree.
Les deux reproductions ad hoc du finding M1 ont utilisé un serveur Vite et un
Chromium privés loopback, sans fichier temporaire dans le dépôt, daemon, profil,
base utilisateur, restart ou déploiement.
## Taille des fichiers
- `packages/ui/src/stores/opencode-data.ts` : ~828 lignes, au-dessus de la cible
de 800.
- `packages/ui/src/stores/instances.ts` : ~2 093 lignes, très au-dessus de la
cible.
- `packages/ui/src/components/tool-call/renderers/task.tsx` : ~647 lignes,
au-dessus du seuil d'avertissement.
- `packages/ui/src/stores/compaction-delta-buffer.ts` : 56 lignes.
Aucun refactor de taille seul n'est recommandé dans cette mission.
## Addendum — disposition du finding M1
### Verdict de suivi
**M1 est clôturé.** La correction de membership intégrée après la revue initiale
traite les suppressions, remplacements et réordonnancements autoritaires sans
réintroduire les remounts qui motivaient le correctif de performance. Le verdict
« corrections demandées » ci-dessus est donc levé pour cette surface. Les
observations R1 et T1 restent des limites non bloquantes, inchangées.
Cette disposition repose sur une revue indépendante du nouveau diff de
`task.tsx`, `render-cost.test.ts` et `fixtures/render-cost.tsx`; elle ne fusionne
pas les chiffres A/B historiques avec les mesures de ce suivi.
### Pourquoi la clé fantôme ne subsiste plus
- À chaque révision de session, le renderer recalcule les identités structurelles
dans le budget existant, au lieu de ne détecter que l'augmentation du nombre de
parts. Une baisse 2→1, un retour 1→2, un remplacement/reorder 2→2 et une part
passée de `tool` à `text` sont donc tous visibles.
- Le scan reste borné par `TASK_MESSAGE_SCAN_LIMIT = 10_000` et collecte au plus
`TASK_STEP_RENDER_LIMIT + 1`, puis affiche les 200 dernières clés.
- Si la séquence de clés est strictement identique, le setter conserve le tableau
précédent. Si elle change, `<For>` associe les lignes aux valeurs de clé plutôt
qu'à leur position : les clés retenues conservent leur composant et leur état,
tandis que les nouvelles identités obtiennent un shell neuf.
- Le snapshot résident reste affiché pendant une lecture invalide; le rescan se
fait sur le retour autoritaire. Une erreur conserve toujours le snapshot et le
retry existants.
### Régressions discriminantes
Les nouveaux cas vrais composants/stores/pages natives vérifient :
1. suppression puis ajout dans le même message, pour chacune des deux positions
initiales : compteur `2→1→2`, nouvelle clé unique, shell/disclosure/scroller et
`scrollTop` de la clé retenue inchangés;
2. remplacement et reorder à cardinalité égale : ordre exact `[step-2, step-0]`,
`step-1` absent et shell de `step-0` conservé;
3. identité de part identique dont le type passe de tool à texte : compteur réduit
et ligne supprimée;
4. fenêtre `201→199` : toujours 200 éléments maximum, puis 199, sans diagnostic de
troncature obsolète;
5. 80 étapes soumises aux deltas de texte non structurels : chaque échantillon
conserve `added=0` et `removed=0`. Les 80 snapshots de données déjà documentés
restent présents; aucune nouvelle revendication ne les élimine.
### Validation fraîche du suivi
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 packages/ui/tests/browser/render-cost.test.ts` — **13/13**, sortie naturelle (~45 s).
- `node --unhandled-rejections=strict --import tsx --test --test-concurrency=1 packages/ui/tests/browser/task-copy.test.ts packages/ui/tests/browser/tool-images.test.ts` — **9/9**, sortie naturelle (~37 s).
- `npm run typecheck --workspace @codenomad/ui` — vert.
- `git diff --check` sur les trois fichiers revus et ce rapport — vert, hors
avertissement LF/CRLF du worktree.
Échantillon descriptif frais des dix deltas à 80 étapes : temps de tâche entre
environ **9,2 et 16,1 ms**, temps synchrone rapporté entre **0,1 et 0,3 ms**, avec
`added=0` et `removed=0` pour chaque échantillon. Il s'agit d'un fixture Chromium
de développement et non d'un nouveau benchmark desktop ou d'une garantie FPS.
Aucun daemon, profil, base utilisateur, restart, déploiement, dépendance, commit
ou push n'a été utilisé pour ce suivi.

View file

@ -1,7 +1,7 @@
import fs from "fs"
import path from "path"
import type { Logger } from "../logger"
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
import { hashPassword, hashPasswordSync, type PasswordHashRecord, verifyPassword } from "./password-hash"
export interface AuthFile {
version: 1
@ -20,6 +20,7 @@ export class AuthStore {
private cachedFile: AuthFile | null = null
private overrideAuth: AuthFile | null = null
private bootstrapUsername: string | null = null
private passwordChanges: Promise<void> = Promise.resolve()
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
@ -65,7 +66,7 @@ export class AuthStore {
const runtime: AuthFile = {
version: 1,
username: params.username,
password: hashPassword(password),
password: hashPasswordSync(password),
userProvided: true,
updatedAt: now,
}
@ -97,7 +98,7 @@ export class AuthStore {
)
}
validateCredentials(username: string, password: string): boolean {
async validateCredentials(username: string, password: string): Promise<boolean> {
const auth = this.load()
if (!auth) {
return false
@ -107,10 +108,21 @@ export class AuthStore {
return false
}
return verifyPassword(password, auth.password)
const valid = await verifyPassword(password, auth.password)
// A password change can commit while scrypt is off-thread. Do not grant a
// session from superseded credentials or retry the login automatically.
return valid && this.load() === auth
}
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
setPassword(params: { password: string; markUserProvided: boolean }): Promise<AuthStatus> {
// Keep admission order and bootstrap state atomic across asynchronous hashing.
// A failed change must not poison subsequent requests.
const change = this.passwordChanges.then(() => this.changePassword(params))
this.passwordChanges = change.then(() => {}, () => {})
return change
}
private async changePassword(params: { password: string; markUserProvided: boolean }): Promise<AuthStatus> {
if (this.overrideAuth) {
throw new Error(
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
@ -127,7 +139,7 @@ export class AuthStore {
const created: AuthFile = {
version: 1,
username: this.bootstrapUsername,
password: hashPassword(params.password),
password: await hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
@ -139,7 +151,7 @@ export class AuthStore {
const next: AuthFile = {
...current,
password: hashPassword(params.password),
password: await hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}

View file

@ -71,7 +71,7 @@ export class AuthManager {
return this.tokenManager.consume(token)
}
validateLogin(username: string, password: string): boolean {
async validateLogin(username: string, password: string): Promise<boolean> {
if (!this.authEnabled) {
return true
}

View file

@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import crypto from "node:crypto"
import { it } from "node:test"
import { hashPassword, verifyPassword, type PasswordHashRecord } from "./password-hash"
it("produces the existing v1 scrypt format and unchanged default parameters", async () => {
const password = "test-password"
const record = await hashPassword(password)
assert.equal(record.algorithm, "scrypt")
assert.equal(Buffer.from(record.saltBase64, "base64").length, 16)
assert.equal(record.keyLength, 64)
assert.deepEqual(record.params, { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 })
const expected = crypto.scryptSync(password, Buffer.from(record.saltBase64, "base64"), record.keyLength, record.params)
assert.equal(record.hashBase64, expected.toString("base64"))
assert.equal(await verifyPassword(password, record), true)
assert.equal(await verifyPassword("wrong-password", record), false)
})
it("verifies existing records using their saved salt, key length and custom scrypt parameters", async () => {
const salt = Buffer.from("custom-salt")
const params = { N: 1024, r: 8, p: 2, maxmem: 16 * 1024 * 1024 }
const record: PasswordHashRecord = {
algorithm: "scrypt", saltBase64: salt.toString("base64"),
hashBase64: crypto.scryptSync("existing-password", salt, 32, params).toString("base64"),
keyLength: 32, params,
}
assert.equal(await verifyPassword("existing-password", record), true)
assert.equal(await verifyPassword("wrong-password", record), false)
assert.equal(await verifyPassword("existing-password", { ...record, hashBase64: Buffer.from("short").toString("base64") }), false)
assert.equal(await verifyPassword("existing-password", { ...record, algorithm: "unsupported" as "scrypt" }), false)
await assert.rejects(verifyPassword("existing-password", { ...record, params: { ...params, N: 3 } }), /scrypt params/)
})

View file

@ -20,11 +20,24 @@ const DEFAULT_SCRYPT_PARAMS = {
maxmem: 32 * 1024 * 1024,
}
export function hashPassword(password: string): PasswordHashRecord {
// Startup-only: AuthManager initializes its CLI/env override before serving HTTP.
export function hashPasswordSync(password: string): PasswordHashRecord {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = crypto.scryptSync(password, salt, keyLength, params)
return buildRecord(salt, derived, keyLength, params)
}
export async function hashPassword(password: string): Promise<PasswordHashRecord> {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = await derivePassword(password, salt, keyLength, params)
return buildRecord(salt, derived, keyLength, params)
}
function buildRecord(salt: Buffer, derived: Buffer, keyLength: number, params: PasswordHashRecord["params"]): PasswordHashRecord {
return {
algorithm: "scrypt",
saltBase64: salt.toString("base64"),
@ -34,16 +47,25 @@ export function hashPassword(password: string): PasswordHashRecord {
}
}
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
export async function verifyPassword(password: string, record: PasswordHashRecord): Promise<boolean> {
if (record.algorithm !== "scrypt") {
return false
}
const salt = Buffer.from(record.saltBase64, "base64")
const expected = Buffer.from(record.hashBase64, "base64")
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
const derived = await derivePassword(password, salt, record.keyLength, record.params)
if (expected.length !== derived.length) {
return false
}
return crypto.timingSafeEqual(expected, Buffer.from(derived))
}
function derivePassword(password: string, salt: Buffer, keyLength: number, params: PasswordHashRecord["params"]): Promise<Buffer> {
return new Promise((resolve, reject) => {
crypto.scrypt(password, salt, keyLength, params, (error, derived) => {
if (error) reject(error)
else resolve(derived)
})
})
}

View file

@ -27,6 +27,7 @@ export async function forwardRuntimeRequest(args: {
args.reply.raw.once("close", disconnect)
if (args.reply.raw.destroyed || args.request.raw.aborted) controller.abort()
try {
controller.signal.throwIfAborted()
const headers = new Headers()
for (const [key, value] of Object.entries(args.headers)) {
if (typeof value === "string") headers.set(key, value)

View file

@ -42,13 +42,25 @@ export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope,
FROM session_message WHERE ${where} AND seq>? AND seq<=? ORDER BY seq LIMIT 512`)
const entries: OutlineEntry[] = [], checkpoints: Array<OutlineCheckpoint & { changed: boolean }> = []
let examined = 0
// Row counts do not bound time: one assistant can contain megabytes of tool
// output. Cooperate during both checkpoint reads and JSON projection. A
// single SQLite step is still synchronous, so this is not a hard deadline.
let turnStarted = performance.now()
do {
signal.throwIfAborted()
const previous = known.find(item => item.after === after)
// Grow the last range to 512 rows before creating another checkpoint.
// Otherwise one append per refresh eventually exhausts the manifest budget.
const end = Math.min(previous && previous !== known.at(-1) ? previous.through : through, through)
const rows = headers.all(...params, after, end)
const rows = []
for (const row of headers.iterate(...params, after, end)) {
signal.throwIfAborted()
rows.push(row)
if (rows.length % 128 === 0 || performance.now() - turnStarted >= 8) {
await yieldTurn(undefined, { signal })
turnStarted = performance.now()
}
}
// If a cached range gained >512 rows, split and rebuild it instead of
// declaring the unseen suffix unchanged.
const last = rows.at(-1)
@ -59,17 +71,22 @@ export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope,
|| chunkEnd >= maximum
if (changed) {
for (const row of project.iterate(...params, after, chunkEnd)) {
signal.throwIfAborted()
const tools = Number(row.tools)
entries.push({ id: String(row.id), seq: Number(row.seq), type: row.type as OutlineEntry["type"],
tools, reasoning: Number(row.reasoning),
...(tools ? { toolName: boundedToolName(row.tool_name) } : {}) })
if (entries.length % 128 === 0) await yieldTurn(undefined, { signal })
if (entries.length % 128 === 0 || performance.now() - turnStarted >= 8) {
await yieldTurn(undefined, { signal })
turnStarted = performance.now()
}
}
}
checkpoints.push({ after, through: chunkEnd, digest, changed })
after = chunkEnd
examined += rows.length
await yieldTurn(undefined, { signal })
turnStarted = performance.now()
// Old ranges shrink after deletions. Reserve a whole block before reading
// another; the page budget is not necessarily a multiple of 512 anymore.
} while (after < through && examined <= 16384 - 512 && checkpoints.length < 512)

View file

@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { DatabaseSync } from "node:sqlite"
import { readSessionOutline } from "./outline-index"
for (const phase of ["headers", "projection"] as const) test(`outline ${phase} admit pending cancellation before consuming an expensive checkpoint`, async (t) => {
const db = new DatabaseSync(":memory:")
let reads = 0
let clock = 0
const controller = new AbortController()
let pending: ReturnType<typeof setImmediate> | undefined
const freeReads = phase === "projection" ? 512 : 0
// Charge a deterministic cost to SQLite payload access instead of asserting
// wall-clock timing on CI. The real SQL/iterator/transaction still execute.
db.function("payload", (data) => {
reads++
if (reads > freeReads) {
clock += 2
pending ??= setImmediate(() => controller.abort())
}
return data
})
t.mock.method(performance, "now", () => clock)
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
CREATE TABLE stored_messages(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT,time_updated INTEGER);
CREATE UNIQUE INDEX stored_session_seq ON stored_messages(session_id,seq);
CREATE VIEW session_message AS SELECT id,session_id,type,seq,payload(data) AS data,time_updated FROM stored_messages;
INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`)
const insert = db.prepare("INSERT INTO stored_messages VALUES (?,'s','assistant',?,?,0)")
for (let i = 0; i < 512; i++) insert.run(`m${i}`, i, '{"content":[{"type":"tool","name":"read"}]}')
try {
await assert.rejects(readSessionOutline(db, { directory: "/repo", projectID: "p", sessionID: "s" },
undefined, controller.signal), /abort/i)
assert(reads - freeReads < 32, `cancelled scan accessed ${reads - freeReads} expensive payloads before giving the event loop a turn`)
assert.equal(db.isTransaction, false, "cancellation releases its read snapshot")
} finally { if (pending) clearImmediate(pending); db.close() }
})

View file

@ -0,0 +1,236 @@
import assert from "node:assert/strict"
import http from "node:http"
import { test } from "node:test"
import Fastify from "fastify"
import replyFrom from "@fastify/reply-from"
import { OpenCode, type OpenCodeClient } from "@opencode/client"
import type { Logger } from "../../logger"
import { createRuntimeFetch } from "../../opencode/compatibility/transport"
import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation"
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../http-server"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => { resolve = done })
return { promise, resolve }
}
function send(url: string, method = "POST") {
let request!: http.ClientRequest
const result = new Promise<number>((resolve, reject) => {
request = http.request(url, { method, agent: false, headers: { "content-type": "application/json" } }, response => {
response.resume()
response.once("end", () => resolve(response.statusCode!))
})
request.once("error", reject)
request.end(method === "POST" ? "{}" : undefined)
})
void result.catch(() => undefined)
return { request, result }
}
type Phase = "connection" | "profile" | "session" | "ownership" | "snapshot" | "environment" | "forward"
async function harness(phase: Phase, nativePreflight = false) {
const blocked = deferred<void>(), started = deferred<void>(), closed = deferred<void>()
const cleanupFinished = deferred<void>()
const nativeClosed = deferred<void>()
let signal: AbortSignal | undefined
const calls = { session: 0, snapshot: 0, environment: 0, forward: 0, complete: 0, invalidation: 0 }
const hold = async (at: Phase, requestSignal?: AbortSignal) => {
if (phase !== at) return
signal = requestSignal
started.resolve()
// Deliberately ignore cancellation in the fake: the observer must also
// retire locally and fence continuations from non-cancellable shared work.
await blocked.promise
}
const upstream = Fastify()
upstream.all("/*", async (request, reply) => {
if (nativePreflight && request.method === "GET" && request.url === "/api/session/held") {
reply.raw.once("close", () => nativeClosed.resolve())
await hold("session")
return { id: "held", location: { directory: "/repo" } }
}
calls.forward++
await hold("forward")
calls.complete++
return { path: request.url }
})
await upstream.listen({ host: "127.0.0.1", port: 0 })
const upstreamAddress = upstream.server.address()
assert.ok(upstreamAddress && typeof upstreamAddress === "object")
const endpoint = `http://127.0.0.1:${upstreamAddress.port}`
const native = OpenCode.make({ baseUrl: endpoint, fetch: createRuntimeFetch({ url: endpoint }) })
const client = {
session: {
get: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
calls.session++
if (nativePreflight && sessionID === "held") return native.session.get({ sessionID }, options)
if (sessionID !== "witness") await hold("session", options?.signal)
return { id: sessionID, location: { directory: "/repo" } }
},
environment: async (_input: unknown, options?: { signal?: AbortSignal }) => {
calls.environment++
await hold("environment", options?.signal)
},
instructions: { entry: { remove: async () => {}, put: async () => {} } },
},
} as unknown as OpenCodeClient
const connection = {
endpoint: { url: endpoint }, client,
fetch: createRuntimeFetch({ url: endpoint }),
assertCurrent() {}, invalidate() { calls.invalidation++ },
profile: async (requestSignal?: AbortSignal) => { await hold("profile", requestSignal); return "modern" as const },
}
const manager = {
get: () => ({ id: "workspace", path: "/repo" }),
getSharedServiceConnection: async () => { await hold("connection"); return connection },
getSharedServiceEndpoint: async () => connection.endpoint,
getSharedServiceClient: async () => client,
getSharedServiceFetch: async () => connection.fetch,
getInstanceAuthorizationHeader: () => undefined,
getServiceDirectory: () => "/repo",
getServiceDirectoryForPath: async () => "/repo",
getServicePathForPath: async () => "/repo",
getWorktreeIdentityForPath: async () => "repo",
getSessionEnvironment: async (_id: string, requestSignal?: AbortSignal) => {
calls.snapshot++
await hold("snapshot", requestSignal)
return {}
},
ownsDirectory: async () => true,
ownsLocation: async (_id: string, _location: unknown, _client?: unknown, requestSignal?: AbortSignal) => {
await hold("ownership", requestSignal)
return true
},
ownsPath: async () => true,
} as unknown as InstanceProxyWorkspaceManager
const app = Fastify()
const captured: Array<{ request: http.IncomingMessage; response: http.ServerResponse; abortedListeners: number; closeListeners: number }> = []
app.addHook("onRequest", async (request, reply) => {
captured.push({ request: request.raw, response: reply.raw,
abortedListeners: request.raw.listenerCount("aborted"), closeListeners: reply.raw.listenerCount("close") })
if (!request.url.endsWith("/witness")) reply.raw.once("close", () => {
closed.resolve()
// The admission race settles and disposes in the following microtasks.
setImmediate(() => cleanupFinished.resolve())
})
})
await app.register(replyFrom)
const fence = new WorktreeDeletionFence()
registerInstanceProxyRoutes(app, {
workspaceManager: manager, worktreeDeletionFence: fence,
logger: { debug() {}, error() {}, isLevelEnabled() { return false } } as unknown as Logger,
})
await app.listen({ host: "127.0.0.1", port: 0 })
const address = app.server.address()
assert.ok(address && typeof address === "object")
const base = `http://127.0.0.1:${address.port}/workspaces/workspace/instance/api/session`
return {
base, calls, blocked, started, closed, cleanupFinished, captured, fence, nativeClosed,
signal: () => signal,
async dispose() { blocked.resolve(); await app.close(); await upstream.close() },
}
}
for (const action of ["prompt", "compact"] as const) {
test(`disconnect cancels actual Promise SDK HTTP preflight for ${action}`, { timeout: 10_000 }, async () => {
const h = await harness("session", true)
try {
const pending = send(`${h.base}/held/${action}`)
await h.started.promise
pending.request.destroy()
await h.nativeClosed.promise
assert.equal(h.calls.forward, 0)
assert.equal(h.calls.environment, 0)
assert.equal(h.calls.invalidation, 0)
} finally { await h.dispose() }
})
test(`disconnect cancels held ${action} session preflight, without blocking another session`, { timeout: 10_000 }, async t => {
const h = await harness("session")
try {
const pending = send(`${h.base}/held/${action}`)
await h.started.promise
const witnessStarted = performance.now()
const witness = await send(`${h.base}/witness`, "GET").result
t.diagnostic(`another session responded while the preflight was held: ${Math.round(performance.now() - witnessStarted)} ms`)
assert.equal(witness, 200) // causal independence, not a machine-speed threshold
pending.request.destroy()
await h.closed.promise
await h.cleanupFinished.promise
assert.equal(h.signal()?.aborted, true)
h.blocked.resolve()
await new Promise<void>(resolve => setImmediate(resolve))
assert.equal(h.calls.forward, 1, "only the independent witness reached native forwarding")
assert.equal(h.calls.environment, 0)
assert.equal(h.calls.invalidation, 0)
} finally { await h.dispose() }
})
}
for (const phase of ["connection", "profile", "ownership", "snapshot", "environment"] as const) {
test(`disconnect retires a held ${phase} boundary and fences late admission`, { timeout: 10_000 }, async () => {
const h = await harness(phase)
try {
const pending = send(`${h.base}/held/prompt`)
await h.started.promise
pending.request.destroy()
await h.closed.promise
await h.cleanupFinished.promise
if (phase !== "connection") assert.equal(h.signal()?.aborted, true)
const callsAtDisconnect = { ...h.calls }
h.blocked.resolve()
await new Promise<void>(resolve => setImmediate(resolve))
assert.deepEqual(h.calls, callsAtDisconnect, "late shared work must not start any subsequent calls")
assert.equal(h.calls.forward, 0)
assert.equal(h.calls.invalidation, 0)
// A preflight-abandoned mutation cannot keep the deletion fence reserved.
await h.fence.run("repo", ["repo"], async () => {})
for (const entry of h.captured) {
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
assert.equal(entry.response.listenerCount("close"), entry.closeListeners)
}
} finally { await h.dispose() }
})
}
for (const action of ["prompt", "compact"] as const) {
test(`disconnect after native ${action} admission does not interrupt, replay or invalidate execution`, { timeout: 10_000 }, async () => {
const h = await harness("forward")
try {
const pending = send(`${h.base}/held/${action}`)
await h.started.promise
pending.request.destroy()
await h.closed.promise
await h.cleanupFinished.promise
assert.equal(h.calls.forward, 1)
h.blocked.resolve()
await new Promise<void>(resolve => setImmediate(resolve))
assert.equal(h.calls.complete, 1, "the admitted upstream operation can finish after its HTTP observer leaves")
assert.equal(h.calls.forward, 1)
assert.equal(h.calls.invalidation, 0)
} finally { await h.dispose() }
})
}
test("normal POST body close preserves admission; success and denial dispose admission listeners", { timeout: 10_000 }, async () => {
const h = await harness("session")
try {
const pending = send(`${h.base}/held/compact`)
await h.started.promise
assert.equal(h.captured[0].request.complete, true)
assert.equal(h.signal()?.aborted, false)
h.blocked.resolve()
assert.equal(await pending.result, 200)
assert.equal(await send(`${h.base}/held/not-allowed`).result, 403)
await new Promise<void>(resolve => setImmediate(resolve))
for (const entry of h.captured) {
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
// Node's response pipeline retains its own finished-stream callbacks;
// check our admission/forwarding listeners, not those library internals.
assert.equal(entry.response.listeners("close").some(listener => ["close", "disconnect"].includes(listener.name)), false)
}
} finally { await h.dispose() }
})

View file

@ -48,6 +48,7 @@ import type { SideCarManager } from "../sidecars/manager"
import type { PreviewManager } from "../previews/manager"
import { buildPreviewRuntimeBridge, rewritePreviewImportMap, rewritePreviewJavaScriptImports } from "../previews/runtime-bridge"
import { forwardRuntimeRequest } from "../opencode/compatibility/proxy"
import { requestAdmission } from "./request-admission"
import { LOCATION_CONTEXT_HEADER, locationRequestOptions, readLocationContext } from "../opencode/compatibility/location"
import { decodeSessionListScope, prepareLocationImport, readRequestLocations, type SessionListScope } from "../opencode/compatibility/proxy-locations"
import type { RemoteProxySessionManager } from "./remote-proxy"
@ -430,7 +431,7 @@ export interface InstanceProxyWorkspaceManager {
getServicePathForPath?(id: string, candidate: string): Promise<string | undefined>
getSharedServiceClient(): Promise<OpenCodeClient>
getSessionEnvironment(id: string, signal?: AbortSignal): Promise<Record<string, string>>
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): ReturnType<WorkspaceManager["ownsLocation"]>
ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): ReturnType<WorkspaceManager["ownsLocation"]>
ownsDirectory(id: string, directory: string): Promise<boolean>
ownsPath(id: string, candidate: string): Promise<boolean>
}
@ -646,15 +647,31 @@ export function registerInstanceProxyRoutes(app: FastifyInstance, deps: Instance
})
}
async function proxyWorkspaceRequest(args: {
interface InstanceProxyRequestArgs {
request: FastifyRequest
reply: FastifyReply
workspaceManager: InstanceProxyWorkspaceManager
worktreeDeletionFence: WorktreeDeletionFence
logger: Logger
pathSuffix?: string
}) {
}
async function proxyWorkspaceRequest(args: InstanceProxyRequestArgs) {
const admission = requestAdmission(args.request, args.reply)
try {
admission.signal.throwIfAborted()
return await proxyWorkspaceAdmission(args, admission)
} catch (error) {
if (!admission.signal.aborted) throw error
} finally {
admission.dispose()
}
}
async function proxyWorkspaceAdmission(args: InstanceProxyRequestArgs, admission: ReturnType<typeof requestAdmission>) {
const { request, reply, workspaceManager, logger, worktreeDeletionFence } = args
const { signal } = admission
const wait = admission.wait
const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId)
@ -679,15 +696,18 @@ async function proxyWorkspaceRequest(args: {
return
}
const connection = await workspaceManager.getSharedServiceConnection?.(workspaceId)
const endpoint = connection?.endpoint ?? await workspaceManager.getSharedServiceEndpoint(workspaceId)
const clientForRequest = () => connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient()
const connection = await wait(Promise.resolve(workspaceManager.getSharedServiceConnection?.(workspaceId)))
const endpoint = connection?.endpoint ?? await wait(workspaceManager.getSharedServiceEndpoint(workspaceId))
const clientForRequest = () => {
signal.throwIfAborted()
return wait(connection ? Promise.resolve(connection.client) : workspaceManager.getSharedServiceClient())
}
if (!endpoint) {
reply.code(502).send({ error: "OpenCode service is not ready" })
return
}
await connection?.profile()
await wait(Promise.resolve(connection?.profile(signal)))
let locationContext: LocationRef | undefined
try { locationContext = readLocationContext(request.headers[LOCATION_CONTEXT_HEADER]) }
catch { return reply.code(400).send({ error: "Invalid location context" }) }
@ -704,34 +724,36 @@ async function proxyWorkspaceRequest(args: {
return
}
const client = await clientForRequest()
const active = await client.session.active()
const active = await wait(client.session.active({ signal }))
const entries = await Promise.all(Object.entries(active).map(async ([sessionId, status]) => {
try {
const session = await client.session.get({ sessionID: sessionId })
return await workspaceManager.ownsLocation(workspaceId, session.location, client) ? [sessionId, status] as const : null
const session = await wait(client.session.get({ sessionID: sessionId }, { signal }))
return await wait(workspaceManager.ownsLocation(workspaceId, session.location, client, signal)) ? [sessionId, status] as const : null
} catch {
return null
}
}))
signal.throwIfAborted()
reply.send({ data: Object.fromEntries(entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null)) })
return
}
if (pathname.replace(/\/+$/, "") === "/api/project") {
const projects = await (await clientForRequest()).project.list()
const projects = await wait((await clientForRequest()).project.list({ signal }))
const ownedProjects = await Promise.all(projects.map(async (project) => {
if (!await workspaceManager.ownsDirectory(workspaceId, project.canonical)) return null
if (!await wait(workspaceManager.ownsDirectory(workspaceId, project.canonical))) return null
const sandboxes = (await Promise.all(project.sandboxes.map(async (directory) => (
await workspaceManager.ownsDirectory(workspaceId, directory) ? directory : null
await wait(workspaceManager.ownsDirectory(workspaceId, directory)) ? directory : null
)))).filter((directory): directory is string => directory !== null)
return { ...project, sandboxes }
}))
signal.throwIfAborted()
reply.send(ownedProjects.filter((project): project is NonNullable<typeof project> => project !== null))
return
}
const sessionListHasScope = request.method === "GET"
&& pathname.replace(/\/+$/, "") === "/api/session"
&& (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project"))
const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client)
const sessionListScope = await wait(authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId, connection?.client, signal))
if (sessionListScope !== "allowed") {
reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" })
return
@ -793,15 +815,15 @@ async function proxyWorkspaceRequest(args: {
return
}
if (requestLocations.invalid
|| !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))
|| !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client))) {
|| !(await wait(allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories)))
|| !(await wait(allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations, connection?.client, signal)))) {
reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" })
return
}
const translatedDirectories = new Map<string, string>()
for (const directory of new Set(requestLocations.directories)) {
const translated = workspaceManager.getServiceDirectoryForPath
? await workspaceManager.getServiceDirectoryForPath(workspaceId, directory)
? await wait(workspaceManager.getServiceDirectoryForPath(workspaceId, directory))
: directory
if (!translated) {
reply.code(403).send({ error: "Location does not belong to workspace" })
@ -820,12 +842,12 @@ async function proxyWorkspaceRequest(args: {
return
}
if (fileListPath.candidate) {
if (!(await workspaceManager.ownsPath(workspaceId, fileListPath.candidate))) {
if (!(await wait(workspaceManager.ownsPath(workspaceId, fileListPath.candidate)))) {
reply.code(403).send({ error: "Filesystem path does not belong to workspace" })
return
}
const translated = workspaceManager.getServicePathForPath
? await workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate)
? await wait(workspaceManager.getServicePathForPath(workspaceId, fileListPath.candidate))
: fileListPath.candidate
if (!translated) {
reply.code(403).send({ error: "Filesystem path does not belong to workspace" })
@ -836,7 +858,7 @@ async function proxyWorkspaceRequest(args: {
const mutationIdentities = new Set<string>()
if (request.method !== "GET" && request.method !== "HEAD") {
for (const directory of requestLocations.directories) {
const identity = await workspaceManager.getWorktreeIdentityForPath(workspaceId, directory)
const identity = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, directory))
if (!identity) {
reply.code(403).send({ error: "Location does not belong to workspace" })
return
@ -845,14 +867,14 @@ async function proxyWorkspaceRequest(args: {
}
}
const serviceBody = replaceRequestDirectories(targetUrl, imported.body, translatedDirectories, pathname, request.method)
if (promptFiles.invalid || !(await allPathsOwned(workspaceManager, workspaceId, promptFiles.paths))) {
if (promptFiles.invalid || !(await wait(allPathsOwned(workspaceManager, workspaceId, promptFiles.paths)))) {
reply.code(promptFiles.invalid ? 400 : 403).send({ error: "Prompt file does not belong to workspace" })
return
}
const translatedPromptPaths = new Map<string, string>()
for (const candidate of new Set(promptFiles.paths)) {
const translated = workspaceManager.getServicePathForPath
? await workspaceManager.getServicePathForPath(workspaceId, candidate)
? await wait(workspaceManager.getServicePathForPath(workspaceId, candidate))
: candidate
if (!translated) {
reply.code(403).send({ error: "Prompt file does not belong to workspace" })
@ -867,10 +889,10 @@ async function proxyWorkspaceRequest(args: {
directory: requestedDirectory ? translatedDirectories.get(requestedDirectory) ?? serviceDirectory : serviceDirectory,
workspaceID: locationContext?.workspaceID ?? explicitLocations.find(location => location.directory === requestedDirectory)?.workspaceID,
}
const runtimeLocationOptions = locationRequestOptions(runtimeLocation)
const runtimeLocationOptions = { ...locationRequestOptions(runtimeLocation), signal }
if (pathname.replace(/\/+$/, "") === "/api/pty" && request.method === "GET") {
const result = await (await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
const ownership = await Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd)))
const result = await wait((await clientForRequest()).pty.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
const ownership = await wait(Promise.all(result.data.map((pty) => workspaceManager.ownsDirectory(workspaceId, pty.cwd))))
reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) })
return
}
@ -878,8 +900,8 @@ async function proxyWorkspaceRequest(args: {
const ptyId = getPtyRouteId(pathname)
if (ptyId) {
try {
const pty = await (await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
if (!(await workspaceManager.ownsDirectory(workspaceId, pty.data.cwd))) {
const pty = await wait((await clientForRequest()).pty.get({ ptyID: ptyId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
if (!(await wait(workspaceManager.ownsDirectory(workspaceId, pty.data.cwd)))) {
reply.code(403).send({ error: "PTY does not belong to workspace" })
return
}
@ -893,8 +915,8 @@ async function proxyWorkspaceRequest(args: {
}
if (pathname.replace(/\/+$/, "") === "/api/shell" && request.method === "GET") {
const result = await (await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
const ownership = await Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd)))
const result = await wait((await clientForRequest()).shell.list({ location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
const ownership = await wait(Promise.all(result.data.map((shell) => workspaceManager.ownsDirectory(workspaceId, shell.cwd))))
reply.send({ ...result, data: result.data.filter((_, index) => ownership[index]) })
return
}
@ -902,8 +924,8 @@ async function proxyWorkspaceRequest(args: {
const shellId = getShellRouteId(pathname)
if (shellId) {
try {
const shell = await (await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions)
if (!(await workspaceManager.ownsDirectory(workspaceId, shell.data.cwd))) {
const shell = await wait((await clientForRequest()).shell.get({ id: shellId, location: { directory: runtimeLocation.directory } }, runtimeLocationOptions))
if (!(await wait(workspaceManager.ownsDirectory(workspaceId, shell.data.cwd)))) {
reply.code(403).send({ error: "Shell does not belong to workspace" })
return
}
@ -920,7 +942,7 @@ async function proxyWorkspaceRequest(args: {
if (sessionId && !isGlobalFormAction(pathname, request.method)) {
let session
try {
session = await (await clientForRequest()).session.get({ sessionID: sessionId })
session = await wait((await clientForRequest()).session.get({ sessionID: sessionId }, { signal }))
} catch (error) {
if (isInvalidRequestError(error)) {
reply.code(400).send({ error: "Invalid session ID" })
@ -934,12 +956,12 @@ async function proxyWorkspaceRequest(args: {
}
throw error
}
if (!(await workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest()))) {
if (!(await wait(workspaceManager.ownsLocation(workspaceId, session.location, await clientForRequest(), signal)))) {
reply.code(403).send({ error: "Session does not belong to workspace" })
return
}
if (request.method !== "GET" && request.method !== "HEAD") {
const sessionWorktree = await workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory)
const sessionWorktree = await wait(workspaceManager.getWorktreeIdentityForPath(workspaceId, session.location.directory))
if (!sessionWorktree) {
reply.code(403).send({ error: "Session does not belong to workspace" })
return
@ -950,6 +972,7 @@ async function proxyWorkspaceRequest(args: {
const body = applyDefaultWorkspaceLocation(targetUrl, promptBody, request.method, serviceDirectory, requestLocations.directories.length > 0 || sessionListHasScope, Boolean(sessionId) && !isGlobalFormAction(pathname, request.method))
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
signal.throwIfAborted()
const releaseMutation = request.method === "GET" || request.method === "HEAD"
? undefined
: worktreeDeletionFence.enter([...mutationIdentities])
@ -962,53 +985,49 @@ async function proxyWorkspaceRequest(args: {
try {
connection?.assertCurrent()
if (request.method === "POST" && /^\/api\/session\/[^/]+\/(?:prompt|command|shell)\/?$/.test(pathname)) {
const disconnected = new AbortController()
const onDisconnect = () => disconnected.abort()
reply.raw.once("close", onDisconnect)
try {
try {
const signal = AbortSignal.any([disconnected.signal, AbortSignal.timeout(15_000)])
if (reply.raw.destroyed || request.raw.aborted) disconnected.abort()
signal.throwIfAborted()
const variables = await workspaceManager.getSessionEnvironment(workspaceId, signal)
signal.throwIfAborted()
connection?.assertCurrent()
await (await clientForRequest()).session.environment({ sessionID: sessionId!, variables }, { signal })
signal.throwIfAborted()
connection?.assertCurrent()
} catch {
// Never log the SDK error: it can contain the complete environment body.
releaseMutation?.()
logger.error({ workspaceId, sessionId }, "Failed to apply profile environment")
return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE })
}
if (!pathname.replace(/\/$/, "").endsWith("/shell")) {
try {
connection?.assertCurrent()
await syncSessionGitContext(await clientForRequest(), sessionId!, disconnected.signal)
} catch {
// Advisory context must not turn Git recovery into another send blocker.
// Do not log SDK bodies (they may include unrelated session context).
logger.debug({ workspaceId, sessionId }, "Unable to update Git availability context")
}
}
disconnected.signal.throwIfAborted()
const environmentSignal = AbortSignal.any([signal, AbortSignal.timeout(15_000)])
const variables = await wait(workspaceManager.getSessionEnvironment(workspaceId, environmentSignal))
const client = await clientForRequest()
environmentSignal.throwIfAborted()
connection?.assertCurrent()
} finally {
reply.raw.off("close", onDisconnect)
await wait(client.session.environment({ sessionID: sessionId!, variables }, { signal: environmentSignal }))
environmentSignal.throwIfAborted()
connection?.assertCurrent()
} catch {
signal.throwIfAborted()
// Never log the SDK error: it can contain the complete environment body.
releaseMutation?.()
logger.error({ workspaceId, sessionId }, "Failed to apply profile environment")
return reply.code(502).send({ error: SESSION_ENVIRONMENT_FAILED_ERROR_CODE })
}
if (!pathname.replace(/\/$/, "").endsWith("/shell")) {
try {
connection?.assertCurrent()
await wait(syncSessionGitContext(await clientForRequest(), sessionId!, signal))
} catch {
// Advisory context must not turn Git recovery into another send blocker.
// Do not log SDK bodies (they may include unrelated session context).
logger.debug({ workspaceId, sessionId }, "Unable to update Git availability context")
}
}
signal.throwIfAborted()
connection?.assertCurrent()
}
if (connection || workspaceManager.getSharedServiceFetch) {
const headers = sanitizeInstanceProxyRequestHeaders(request.headers, instanceAuthHeader)
delete headers[LOCATION_CONTEXT_HEADER]
if (locationContext) Object.assign(headers, locationRequestOptions({ ...locationContext, directory: translatedDirectories.get(locationContext.directory)! }, { includeDirectory: true })?.headers)
if (globalFormLocation) headers["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!)
const runtimeFetch = connection?.fetch ?? await wait(workspaceManager.getSharedServiceFetch!())
signal.throwIfAborted()
return await forwardRuntimeRequest({
request, reply, url: targetUrl.toString(), body, headers,
fetch: connection?.fetch ?? await workspaceManager.getSharedServiceFetch!(), release: releaseMutation,
fetch: runtimeFetch, release: releaseMutation,
invalidate: () => connection ? connection.invalidate() : workspaceManager.invalidateSharedServiceConnection?.(),
})
}
signal.throwIfAborted()
return reply.from(targetUrl.toString(), {
...(body !== request.body ? { body } : {}),
rewriteRequestHeaders: (_originalRequest, headers) => {
@ -1148,9 +1167,9 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works
return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean)
}
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient) {
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[], client?: OpenCodeClient, signal?: AbortSignal) {
const unique = new Map(locations.map((location) => [JSON.stringify([location.directory, location.workspaceID]), location]))
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client)))).every(Boolean)
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location, client, signal)))).every(Boolean)
}
async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) {
@ -1199,6 +1218,7 @@ async function authorizeSessionList(
manager: InstanceProxyWorkspaceManager,
workspaceId: string,
client?: OpenCodeClient,
signal?: AbortSignal,
): Promise<"allowed" | "invalid" | "foreign"> {
if (method !== "GET" || targetUrl.pathname.replace(/\/+$/, "") !== "/api/session") return "allowed"
const cursors = targetUrl.searchParams.getAll("cursor")
@ -1209,10 +1229,11 @@ async function authorizeSessionList(
// A project cursor embeds its original wide scope; never reinterpret it as
// a directory cursor when Git authority is unavailable.
if (scope.project && !(await readGitStatus()).available) return "foreign"
signal?.throwIfAborted()
for (const key of ["directory", "location[directory]", "project", "subpath"]) {
targetUrl.searchParams.delete(key)
}
return ownsSessionListScope(manager, workspaceId, scope, client)
return ownsSessionListScope(manager, workspaceId, scope, client, signal)
}
const projects = targetUrl.searchParams.getAll("project")
@ -1228,10 +1249,11 @@ async function authorizeSessionList(
const project = projects[0]
const subpath = subpaths[0]
if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid"
const ownership = await ownsSessionListScope(manager, workspaceId, { project, subpath }, client)
const ownership = await ownsSessionListScope(manager, workspaceId, { project, subpath }, client, signal)
if (ownership !== "allowed") return ownership
if (!(await readGitStatus()).available) {
if (directory && !await manager.ownsLocation(workspaceId, { directory }, client)) return "foreign"
signal?.throwIfAborted()
if (directory && !await manager.ownsLocation(workspaceId, { directory }, client, signal)) return "foreign"
// Narrow the initial query, preserving native directory-scoped pagination.
// Unlike a project cursor this request has no already-established scope.
const root = manager.getServiceDirectory?.(workspaceId) ?? manager.get(workspaceId)?.path
@ -1252,20 +1274,25 @@ async function ownsSessionListScope(
workspaceId: string,
scope: SessionListScope,
client?: OpenCodeClient,
signal?: AbortSignal,
): Promise<"allowed" | "foreign"> {
signal?.throwIfAborted()
if (scope.directory) {
const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client)
const owned = await manager.ownsLocation(workspaceId, { directory: scope.directory }, client, signal)
return owned ? "allowed" : "foreign"
}
if (!scope.project) return "foreign"
const project = (await (client ?? await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project)
const serviceClient = client ?? await manager.getSharedServiceClient()
signal?.throwIfAborted()
const project = (await serviceClient.project.list({ signal })).find((candidate) => candidate.id === scope.project)
signal?.throwIfAborted()
if (!project) return "foreign"
const directory = scope.subpath === undefined
? project.canonical
: /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical)
? path.win32.resolve(project.canonical, scope.subpath)
: path.posix.resolve(project.canonical, scope.subpath)
const owned = await manager.ownsLocation(workspaceId, { directory }, client)
const owned = await manager.ownsLocation(workspaceId, { directory }, client, signal)
return owned ? "allowed" : "foreign"
}

View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict"
import { EventEmitter } from "node:events"
import { test } from "node:test"
import type { FastifyReply, FastifyRequest } from "fastify"
import { requestAdmission } from "./request-admission"
function fixture() {
const request = Object.assign(new EventEmitter(), { aborted: false })
const reply = Object.assign(new EventEmitter(), { destroyed: false, writableFinished: false })
const admission = requestAdmission({ raw: request } as unknown as FastifyRequest, { raw: reply } as unknown as FastifyReply)
return { request, reply, admission }
}
test("normal upload close and completed response close do not revoke admission", () => {
const h = fixture()
h.request.emit("close")
assert.equal(h.admission.signal.aborted, false)
h.reply.writableFinished = true
h.reply.emit("close")
assert.equal(h.admission.signal.aborted, false)
h.admission.dispose()
assert.equal(h.request.listenerCount("aborted"), 0)
assert.equal(h.reply.listenerCount("close"), 0)
})
test("aborted uploads and incomplete response close retire a held observer and dispose listeners", async () => {
for (const upload of [true, false]) {
const h = fixture()
const pending = h.admission.wait(new Promise<void>(() => {}))
const rejected = assert.rejects(pending, { name: "AbortError" })
if (upload) h.request.emit("aborted")
else h.reply.emit("close")
await rejected
h.admission.dispose()
assert.equal(h.request.listenerCount("aborted"), 0)
assert.equal(h.reply.listenerCount("close"), 0)
}
})
test("already disconnected admission observes late rejected work without unhandled rejection", async () => {
const h = fixture()
h.reply.emit("close")
await assert.rejects(h.admission.wait(Promise.reject(new Error("late shared failure"))), { name: "AbortError" })
h.admission.dispose()
})

View file

@ -0,0 +1,41 @@
import type { FastifyReply, FastifyRequest } from "fastify"
/** Cancellation belongs to this HTTP observer, never to shared connection work.
* IncomingMessage.close also fires after a normal request body; only an aborted
* upload or the downstream response closing before completion revokes admission.
*/
export function requestAdmission(request: FastifyRequest, reply: FastifyReply) {
const controller = new AbortController()
const abort = () => controller.abort()
const close = () => { if (!reply.raw.writableFinished) abort() }
request.raw.once("aborted", abort)
reply.raw.once("close", close)
if (request.raw.aborted || reply.raw.destroyed) abort()
return {
signal: controller.signal,
async wait<T>(pending: Promise<T>): Promise<T> {
if (controller.signal.aborted) {
// A caller may already have allocated the Promise. Observe its rejection
// even when admission was revoked immediately before this boundary.
void pending.catch(() => undefined)
controller.signal.throwIfAborted()
}
let rejectAbort!: () => void
const aborted = new Promise<never>((_resolve, reject) => {
rejectAbort = () => reject(controller.signal.reason)
controller.signal.addEventListener("abort", rejectAbort, { once: true })
})
try {
const result = await Promise.race([pending, aborted])
controller.signal.throwIfAborted()
return result
} finally {
controller.signal.removeEventListener("abort", rejectAbort)
}
},
dispose() {
request.raw.off("aborted", abort)
reply.raw.off("close", close)
},
}
}

View file

@ -0,0 +1,272 @@
import assert from "node:assert/strict"
import crypto from "node:crypto"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { performance } from "node:perf_hooks"
import { it } from "node:test"
import Fastify from "fastify"
import pino from "pino"
import { AuthManager } from "../../auth/manager"
import { registerAuthRoutes } from "./auth"
const username = "fixture-user"
const password = "fixture-password"
async function fixture(options: { persist?: boolean; generateToken?: boolean; password?: string; dangerouslySkipAuth?: boolean } = {}) {
const tempRoot = path.join(os.tmpdir(), "opencode")
await fs.mkdir(tempRoot, { recursive: true })
const directory = await fs.mkdtemp(path.join(tempRoot, "codenomad-auth-responsive-"))
// An existing v1 record, produced independently with the old synchronous API.
const salt = Buffer.from("0123456789abcdef")
const params = { N: 16384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 }
if (options.persist !== false) await fs.writeFile(path.join(directory, "auth.json"), JSON.stringify({
version: 1,
username,
password: {
algorithm: "scrypt", saltBase64: salt.toString("base64"),
hashBase64: crypto.scryptSync(password, salt, 64, params).toString("base64"),
keyLength: 64, params,
},
userProvided: true,
updatedAt: "2026-01-01T00:00:00.000Z",
}))
const manager = new AuthManager({
configPath: path.join(directory, "config.yaml"), username, generateToken: false, ...options,
}, pino({ level: "silent" }))
const app = Fastify({ logger: false })
registerAuthRoutes(app, { authManager: manager })
return {
app, manager, directory,
async close() {
await app.close()
await fs.rm(directory, { recursive: true, force: true })
},
}
}
function observeWork<T>(work: () => T, state: { pending: number; completed: number; maxCallMs: number }): T {
state.pending++
const start = performance.now()
try {
const result = work()
state.maxCallMs = Math.max(state.maxCallMs, performance.now() - start)
if (result && typeof (result as unknown as Promise<unknown>).then === "function") {
return Promise.resolve(result).finally(() => { state.pending--; state.completed++ }) as T
}
state.pending--
state.completed++
return result
} catch (error) {
state.pending--
state.completed++
throw error
}
}
it("services a witness endpoint while a bounded burst of login verifications is pending", async (t) => {
const f = await fixture()
try {
const state = { pending: 0, completed: 0, maxCallMs: 0 }
f.app.get("/fixture-heartbeat", async () => ({ pending: state.pending, completed: state.completed }))
const validate = f.manager.validateLogin.bind(f.manager)
let probe: Promise<{ pending: number; completed: number }> | undefined
let probeMs = 0
f.manager.validateLogin = (...args) => {
if (!probe) {
const start = performance.now()
probe = new Promise((resolve, reject) => setImmediate(() => {
f.app.inject({ method: "GET", url: "/fixture-heartbeat" }).then((response) => {
probeMs = performance.now() - start
resolve(response.json())
}, reject)
}))
}
return observeWork(() => validate(...args), state)
}
const start = performance.now()
const responses = await Promise.all(Array.from({ length: 8 }, () => f.app.inject({
method: "POST", url: "/api/auth/login", payload: { username, password: "invalid-password" },
})))
const witness = await probe!
t.diagnostic(JSON.stringify({ operation: "login", totalMs: performance.now() - start, probeMs, ...state, witness }))
for (const response of responses) {
assert.equal(response.statusCode, 401)
assert.deepEqual(response.json(), { error: "Invalid credentials" })
assert.equal(response.headers["set-cookie"], undefined)
}
assert.ok(witness.pending > 0, "the HTTP witness must run before all expensive verifications finish")
} finally {
await f.close()
}
})
it("awaits login results for valid credentials, rejects wrong usernames and authenticates the cookie", async () => {
const f = await fixture()
try {
const responses = await Promise.all([
{ username, password },
{ username: "not-the-user", password },
{ username, password: "not-the-password" },
].map((payload) => f.app.inject({ method: "POST", url: "/api/auth/login", payload })))
assert.deepEqual(responses.map((response) => response.statusCode), [200, 401, 401])
assert.deepEqual(responses[0].json(), { ok: true })
const cookie = String(responses[0].headers["set-cookie"])
assert.match(cookie, /HttpOnly; Path=\/; SameSite=Lax/)
const status = await f.app.inject({ method: "GET", url: "/api/auth/status", headers: { cookie } })
assert.deepEqual(status.json(), { authenticated: true, username, passwordUserProvided: true })
} finally {
await f.close()
}
})
it("rejects a pending login verified against a password replaced before verification returns", async (t) => {
const f = await fixture()
let release: (() => void) | undefined
let pendingLogin: Promise<unknown> | undefined
try {
let notifyDerived!: () => void
const derived = new Promise<void>((resolve) => { notifyDerived = resolve })
const scrypt = crypto.scrypt.bind(crypto)
let held = false
// Run real scrypt, but hold the first old-password callback until the new
// password is durably committed. No scheduler or elapsed-time assumptions.
const scryptMock = t.mock.method(crypto, "scrypt", (
input: crypto.BinaryLike, salt: crypto.BinaryLike, keyLength: number,
options: crypto.ScryptOptions, callback: (error: Error | null, key: Buffer) => void,
) => scrypt(input, salt, keyLength, options, (error, key) => {
if (input === password && !held) {
held = true
release = () => { release = undefined; callback(error, key) }
notifyDerived()
} else {
callback(error, key)
}
}))
const login = f.app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } })
.then((response) => response)
pendingLogin = login
await derived
const session = f.manager.createSession(username)
const changed = await f.app.inject({
method: "POST", url: "/api/auth/password", payload: { password: "replacement-password" },
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
})
assert.equal(changed.statusCode, 200)
assert.equal(await f.manager.validateLogin(username, "replacement-password"), true)
release!()
const stale = await login
assert.equal(stale.statusCode, 401, "a successful derivation of superseded credentials must not authorize a login")
assert.deepEqual(stale.json(), { error: "Invalid credentials" })
assert.equal(stale.headers["set-cookie"], undefined)
assert.equal(scryptMock.mock.callCount(), 3, "do not replay the old login against the replacement password")
assert.equal(await f.manager.validateLogin(username, password), false)
} finally {
release?.()
await pendingLogin
t.mock.restoreAll()
await f.close()
}
})
it("preserves unauthenticated, runtime-override and disabled-auth password errors", async () => {
for (const options of [{}, { password }, { dangerouslySkipAuth: true }]) {
const f = await fixture(options)
try {
if (!options.dangerouslySkipAuth) {
const unauthorized = await f.app.inject({ method: "POST", url: "/api/auth/password", payload: { password } })
assert.equal(unauthorized.statusCode, 401)
assert.deepEqual(unauthorized.json(), { error: "Unauthorized" })
}
if (options.password || options.dangerouslySkipAuth) {
const session = f.manager.createSession(username)
const response = await f.app.inject({
method: "POST", url: "/api/auth/password", payload: { password: "next-password" },
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
})
assert.equal(response.statusCode, 409)
assert.match(response.body, options.password ? /provided via CLI\/env/ : /authentication is disabled/)
assert.match(String(response.headers["content-type"]), /^text\/plain/)
}
if (options.password) assert.equal(await f.manager.validateLogin(username, password), true)
if (options.dangerouslySkipAuth) assert.equal(await f.manager.validateLogin("any", "anything"), true)
} finally {
await f.close()
}
}
})
it("keeps bootstrap password changes ordered and recovers the queue after a persistence failure", async () => {
const f = await fixture({ persist: false, generateToken: true })
try {
assert.equal(await f.manager.validateLogin(username, password), false)
assert.deepEqual(f.manager.getStatus(), { username, passwordUserProvided: false })
const authPath = path.join(f.directory, "auth.json")
await fs.mkdir(authPath)
const session = f.manager.createSession(username)
const failed = await f.app.inject({
method: "POST", url: "/api/auth/password", payload: { password: "failed-password" },
headers: { cookie: `${f.manager.getCookieName()}=${session.id}` },
})
assert.equal(failed.statusCode, 409)
assert.match(String(failed.headers["content-type"]), /^text\/plain/)
assert.deepEqual(f.manager.getStatus(), { username, passwordUserProvided: false })
await fs.rmdir(authPath)
const changes = ["first-password", "second-password", "final-password"]
await Promise.all(changes.map((next) => f.manager.setPassword(next)))
assert.equal(await f.manager.validateLogin(username, changes[0]), false)
assert.equal(await f.manager.validateLogin(username, changes[2]), true)
const persisted = JSON.parse(await fs.readFile(authPath, "utf8"))
assert.equal(persisted.username, username)
assert.equal(persisted.userProvided, true)
// Independent synchronous verification proves persisted format compatibility.
const record = persisted.password
assert.equal(crypto.scryptSync(changes[2], Buffer.from(record.saltBase64, "base64"), record.keyLength, record.params)
.toString("base64"), record.hashBase64)
} finally {
await f.close()
}
})
it("services a witness endpoint during password derivation and awaits persistence", async (t) => {
const f = await fixture()
try {
const session = f.manager.createSession(username)
const cookie = `${f.manager.getCookieName()}=${session.id}`
const state = { pending: 0, completed: 0, maxCallMs: 0 }
f.app.get("/fixture-heartbeat", async () => ({ pending: state.pending, completed: state.completed }))
const setPassword = f.manager.setPassword.bind(f.manager)
let probe: Promise<{ pending: number; completed: number }> | undefined
let probeMs = 0
f.manager.setPassword = (...args) => {
if (!probe) {
const start = performance.now()
probe = new Promise((resolve, reject) => setImmediate(() => {
f.app.inject({ method: "GET", url: "/fixture-heartbeat" }).then((response) => {
probeMs = performance.now() - start
resolve(response.json())
}, reject)
}))
}
return observeWork(() => setPassword(...args), state)
}
const start = performance.now()
const responses = await Promise.all(Array.from({ length: 4 }, (_, index) => f.app.inject({
method: "POST", url: "/api/auth/password", headers: { cookie },
payload: { password: `new-password-${index}` },
})))
const witness = await probe!
t.diagnostic(JSON.stringify({ operation: "password", totalMs: performance.now() - start, probeMs, ...state, witness }))
for (const response of responses) {
assert.equal(response.statusCode, 200)
assert.deepEqual(response.json(), { ok: true, username, passwordUserProvided: true })
}
assert.equal(await f.manager.validateLogin(username, "new-password-3"), true)
const persisted = JSON.parse(await fs.readFile(path.join(f.directory, "auth.json"), "utf8"))
assert.equal(persisted.version, 1)
assert.equal(persisted.password.algorithm, "scrypt")
assert.ok(witness.pending > 0, "the HTTP witness must run before all expensive password changes finish")
} finally {
await f.close()
}
})

View file

@ -98,7 +98,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/auth/login", async (request, reply) => {
const body = LoginSchema.parse(request.body ?? {})
const ok = deps.authManager.validateLogin(body.username, body.password)
const ok = await deps.authManager.validateLogin(body.username, body.password)
if (!ok) {
reply.code(401).send({ error: "Invalid credentials" })
return
@ -147,7 +147,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
const body = PasswordSchema.parse(request.body ?? {})
try {
const status = deps.authManager.setPassword(body.password)
const status = await deps.authManager.setPassword(body.password)
reply.send({ ok: true, ...status })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)

View file

@ -267,10 +267,12 @@ export class WorkspaceManager {
return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory))
}
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient): Promise<boolean> {
async ownsLocation(id: string, location: LocationRef, client?: OpenCodeClient, signal?: AbortSignal): Promise<boolean> {
signal?.throwIfAborted()
const record = this.workspaces.get(id)
if (!record?.[WORKSPACE_STATE].published) return false
const directory = await this.getServiceDirectoryForPath(id, location.directory)
signal?.throwIfAborted()
if (!directory) return false
if (location.workspaceID === undefined) return true
// The directory fence alone cannot authorize a legacy native workspace.
@ -279,8 +281,8 @@ export class WorkspaceManager {
try {
const requested = readLocationRef({ ...location, directory })
const resolved = readLocationRef(client
? await client.location.get({ location: { directory } }, locationRequestOptions(requested))
: await this.sharedService.validateLocation(requested, undefined, record[WORKSPACE_STATE].serviceOptions))
? await client.location.get({ location: { directory } }, { ...locationRequestOptions(requested), signal })
: await this.sharedService.validateLocation(requested, { signal }, record[WORKSPACE_STATE].serviceOptions))
return sameLocation(requested, resolved)
} catch {
return false

View file

@ -127,7 +127,7 @@ export class OpenCodeSharedService {
try {
connection = await this.connect(serviceOptions, requestOptions?.deadlineAt)
const nativeRequestOptions = requestOptions?.signal ? { signal: requestOptions.signal } : undefined
return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection)
return this.invalidateAfterStream(connection.client.event.subscribe(nativeRequestOptions), connection, requestOptions?.signal)
} catch (error) {
if (connection && !requestOptions?.signal?.aborted) this.invalidateConnection(connection)
throw error
@ -291,14 +291,28 @@ export class OpenCodeSharedService {
}
}
private async *invalidateAfterStream(events: AsyncIterable<OpenCodeEvent>, connection: ServiceConnection) {
private async *invalidateAfterStream(events: AsyncIterable<OpenCodeEvent>, connection: ServiceConnection, signal?: AbortSignal) {
const iterator = events[Symbol.asyncIterator]()
try {
for await (const event of events) {
while (true) {
let result: IteratorResult<OpenCodeEvent>
try {
result = await iterator.next()
} catch (error) {
if (!signal?.aborted) this.invalidateConnection(connection)
throw error
}
if (result.done) {
if (!signal?.aborted) this.invalidateConnection(connection)
return
}
connection.assertCurrent()
yield event
// Consumer return/throw and subscriber-local abort are not failures of
// the shared source. Uncancelled iterator EOF/errors still invalidate.
yield result.value
}
} finally {
this.invalidateConnection(connection)
await iterator.return?.()
}
}

View file

@ -0,0 +1,116 @@
import assert from "node:assert/strict"
import { test, type TestContext } from "node:test"
import type { ServerResponse } from "node:http"
import Fastify from "fastify"
import type { OpenCodeEvent } from "@opencode/client"
import { OpenCodeSharedService } from "./opencode-service"
async function transport(t: TestContext) {
const upstream = Fastify()
const streams: ServerResponse[] = []
upstream.get("/api/event", (_request, reply) => {
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.flushHeaders()
reply.hijack()
streams.push(reply.raw)
reply.raw.write(`data: ${JSON.stringify({ id: "connected", created: Date.now(), type: "server.connected", data: {} })}\n\n`)
})
upstream.get("/api/info", async () => ({ version: "fixture" }))
await upstream.listen({ host: "127.0.0.1", port: 0 })
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
const service = new OpenCodeSharedService()
// Only CLI lifecycle/upstream are fixtures. SDK sharing, cancellation, SSE
// parsing and the CodeNomad connection fences remain real.
const client = await service.client({ kind: "lifecycle", identity: "isolated-event-transport",
lifecycle: { discover: async () => endpoint, ensure: async () => endpoint },
})
t.after(async () => {
await service.shutdown()
for (const stream of streams) stream.destroy()
await upstream.close()
})
return { service, client, streams,
send(event: OpenCodeEvent) { streams.at(-1)!.write(`data: ${JSON.stringify(event)}\n\n`) },
}
}
for (const mode of ["abort", "return", "throw"] as const) {
test(`one subscriber's ${mode} cannot invalidate another subscriber or the healthy client`, async t => {
const f = await transport(t)
const controller = new AbortController()
const other = f.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
t.after(async () => { controller.abort(); await other.return?.() })
assert.equal((await other.next()).value?.type, "server.connected")
const local = new AbortController()
const wrapped = (await f.service.subscribe({ signal: local.signal }))[Symbol.asyncIterator]()
t.after(async () => { local.abort(); await wrapped.return?.() })
assert.equal((await wrapped.next()).value?.type, "server.connected")
assert.equal(f.streams.length, 1, "both consumers must share one physical HTTP stream")
if (mode === "abort") {
const waiting = wrapped.next()
local.abort()
assert.equal((await waiting).done, true)
} else if (mode === "throw") {
const failure = new Error("consumer failure")
await assert.rejects(wrapped.throw!(failure), error => error === failure)
} else await wrapped.return?.()
assert.equal((await f.client.server.info()).version, "fixture")
assert.equal((await f.service.acquire()).client, f.client)
const next = other.next()
const renamed = { id: "after-local-stop", created: Date.now(), type: "session.renamed" as const,
durable: { aggregateID: "session", seq: 1, version: 1 as const },
data: { sessionID: "session", title: "still shared" },
}
f.send(renamed)
assert.deepEqual((await next).value, renamed)
})
}
test("an already-aborted subscriber never opens a stream or invalidates the healthy connection", async t => {
const f = await transport(t)
const controller = new AbortController()
controller.abort()
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
assert.equal((await wrapped.next()).done, true)
assert.equal((await f.service.acquire()).client, f.client)
assert.equal(f.streams.length, 0)
})
test("genuine upstream EOF invalidates its connection but late cleanup cannot invalidate its replacement", async t => {
const f = await transport(t)
const controller = new AbortController()
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
t.after(async () => { controller.abort(); await wrapped.return?.() })
assert.equal((await wrapped.next()).value?.type, "server.connected")
const ended = wrapped.next()
f.streams[0].end()
assert.equal((await ended).done, true)
const replacement = await f.service.acquire()
assert.notEqual(replacement.client, f.client)
await wrapped.return?.()
assert.equal(await f.service.acquire(), replacement)
})
test("abrupt upstream transport failure still invalidates its connection", async t => {
const f = await transport(t)
const controller = new AbortController()
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
t.after(async () => { controller.abort(); await wrapped.return?.() })
assert.equal((await wrapped.next()).value?.type, "server.connected")
const failed = wrapped.next()
f.streams[0].destroy()
await assert.rejects(failed)
assert.notEqual((await f.service.acquire()).client, f.client)
})
test("an obsolete iterator cannot invalidate its replacement connection or publish queued events", async t => {
const f = await transport(t)
const controller = new AbortController()
const wrapped = (await f.service.subscribe({ signal: controller.signal }))[Symbol.asyncIterator]()
t.after(async () => { controller.abort(); await wrapped.return?.() })
assert.equal((await wrapped.next()).value?.type, "server.connected")
f.service.invalidate()
const replacement = await f.service.acquire()
await assert.rejects(wrapped.next())
assert.equal(await f.service.acquire(), replacement)
})

View file

@ -1,4 +1,4 @@
import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Copy } from "lucide-solid"
import type { ToolState } from "../../../types/tool-state"
@ -82,20 +82,23 @@ function TaskToolCallRow(props: {
const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const rendered = createMemo(() => {
const part = toolPart()
if (!part) return null
return props.renderToolCall({
toolCall: part as any,
messageId: messageId(),
messageVersion: messageVersion(),
partVersion: partVersion(),
sessionId: props.sessionId,
// Keep one shell for this key. Native page reprojections and unrelated text
// revisions must update its props, not discard disclosure/scroll state.
// Rows follow their tool identity, not their position in the bounded window.
// A new identity still needs a fresh shell with its default disclosure.
// Nonzero-arity Show callbacks mount untracked; getters update the child props.
return <Show when={props.toolKey} keyed>{(_key) => (
<Show when={toolPart()}>{(_part) => props.renderToolCall({
get toolCall() { partVersion(); return toolPart()! },
get messageId() { return messageId() },
get messageVersion() { return messageVersion() },
// Native page hydration resets the per-part revision to zero. Let
// Markdown use its content hash instead of pinning changed output to 0;
// the getter above still fences versioned in-place snapshot updates.
get sessionId() { return props.sessionId },
forceCollapsed: true,
})
})
return <>{rendered()}</>
})}</Show>
)}</Show>
}
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
@ -229,15 +232,9 @@ export const taskRenderer: ToolRenderer = {
const [childToolsTruncated, setChildToolsTruncated] = createSignal(false)
let indexedSessionId = ""
let indexedMessageCount = 0
let indexedMessageTail = ""
const indexedPartCounts = new Map<string, number>()
function resetChildToolIndex(nextSessionId: string) {
indexedSessionId = nextSessionId
indexedMessageCount = 0
indexedMessageTail = ""
indexedPartCounts.clear()
setChildToolKeys([])
setChildToolsTruncated(false)
}
@ -265,15 +262,11 @@ export const taskRenderer: ToolRenderer = {
keys.unshift(`${messageId}::${partId}`)
}
if (idx >= oldestScannedIndex) setChildToolsTruncated(true)
indexedPartCounts.set(messageId, partIds.length)
return keys
}
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
indexedSessionId = sessionId
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
indexedPartCounts.clear()
setChildToolsTruncated(false)
const nextKeys: string[] = []
@ -284,93 +277,33 @@ export const taskRenderer: ToolRenderer = {
const keys = scanMessageToolParts(messageIds[index], 0, scanLimit - nextKeys.length, budget)
for (let keyIndex = keys.length - 1; keyIndex >= 0; keyIndex -= 1) nextKeys.unshift(keys[keyIndex])
}
for (let index = oldestScannedIndex; index < messageIds.length; index += 1) {
const messageId = messageIds[index]
if (!indexedPartCounts.has(messageId)) indexedPartCounts.set(messageId, store.getMessage(messageId)?.partIds.length ?? 0)
}
setChildToolsTruncated((truncated) => isTaskScanTruncated(truncated, oldestScannedIndex > 0, isTaskStepListTruncated(nextKeys.length)))
setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT))
const keys = nextKeys.slice(-TASK_STEP_RENDER_LIMIT)
setChildToolKeys(previous => previous.length === keys.length && previous.every((key, index) => key === keys[index]) ? previous : keys)
}
createEffect(() => {
const id = childSessionId()
const loaded = childSessionLoaded()
if (!id || !loaded) {
if (indexedSessionId) {
resetChildToolIndex("")
}
if (!id || (indexedSessionId && indexedSessionId !== id)) {
resetChildToolIndex("")
}
if (!id) return
if (!loaded) {
// Invalidation requests a fresh page, but its resident display snapshot
// is still valid. Do not unmount every step while that read is pending.
// Rebuild membership after the authoritative page arrives.
return
}
// We use the session revision as the reactive change point, but avoid
// rescanning the entire session on every update.
// Authoritative pages can remove, replace or reorder parts without changing
// message counts. Revalidate only bounded structural identities (not output
// payloads); unchanged keys retain their array and keyed tool shells.
store.getSessionRevision(id)
untrack(() => {
const messageIds = store.getSessionMessageIds(id)
if (!indexedSessionId || indexedSessionId !== id) {
fullRescanChildTools(id, messageIds)
return
}
// Detect structural changes (reorder/shrink) and fall back to a full rescan.
if (messageIds.length < indexedMessageCount) {
fullRescanChildTools(id, messageIds)
return
}
if (indexedMessageCount > 0) {
const expectedTailIndex = indexedMessageCount - 1
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
fullRescanChildTools(id, messageIds)
return
}
}
const appendedKeys: string[] = []
const budget = { remaining: TASK_MESSAGE_SCAN_LIMIT }
// Scan any new messages appended since last index.
const appendedStart = Math.max(indexedMessageCount, messageIds.length - TASK_MESSAGE_SCAN_LIMIT)
if (appendedStart > indexedMessageCount) setChildToolsTruncated(true)
for (let idx = appendedStart; idx < messageIds.length && budget.remaining > 0; idx += 1) {
const messageId = messageIds[idx]
appendedKeys.push(...scanMessageToolParts(messageId, 0, TASK_STEP_RENDER_LIMIT, budget))
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
setChildToolsTruncated(true)
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
}
}
// Scan the bounded indexed window so out-of-order updates are not missed.
const existingCount = Math.min(indexedMessageCount, messageIds.length)
const windowStart = Math.max(0, existingCount - TASK_MESSAGE_SCAN_LIMIT)
for (let idx = windowStart; idx < existingCount && budget.remaining > 0; idx += 1) {
const messageId = messageIds[idx]
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
const record = store.getMessage(messageId)
const nextPartCount = record?.partIds.length ?? 0
if (nextPartCount > previousPartCount) {
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount, TASK_STEP_RENDER_LIMIT, budget))
if (appendedKeys.length > TASK_STEP_RENDER_LIMIT) {
setChildToolsTruncated(true)
appendedKeys.splice(0, appendedKeys.length - TASK_STEP_RENDER_LIMIT)
}
}
}
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
if (indexedPartCounts.size > TASK_MESSAGE_SCAN_LIMIT) {
const retainedIds = new Set(messageIds.slice(-TASK_MESSAGE_SCAN_LIMIT))
for (const messageId of indexedPartCounts.keys()) if (!retainedIds.has(messageId)) indexedPartCounts.delete(messageId)
}
if (appendedKeys.length > 0) {
if (childToolKeys().length + appendedKeys.length > TASK_STEP_RENDER_LIMIT) setChildToolsTruncated(true)
setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT))
}
fullRescanChildTools(id, store.getSessionMessageIds(id))
})
})
const promptContent = createMemo(() => {
@ -591,12 +524,12 @@ export const taskRenderer: ToolRenderer = {
}
>
<div class="tool-call-task-summary">
<Index each={childToolKeys()}>
<For each={childToolKeys()}>
{(key) => (
<Show when={renderToolCall}>
{(render) => (
<TaskToolCallRow
toolKey={key()}
toolKey={key}
store={store}
sessionId={childSessionId()}
renderToolCall={render()}
@ -604,7 +537,7 @@ export const taskRenderer: ToolRenderer = {
)}
</Show>
)}
</Index>
</For>
</div>
{scrollHelpers?.renderSentinel?.()}
</div>

View file

@ -14,6 +14,23 @@ const logger = {
}
describe("attachEventSourceHandlers", () => {
it("separates malformed data from callback failures without reconnect or replay", () => {
const source = new FakeEventSource()
const errors: string[] = []
const received: unknown[] = []
let reconnects = 0
attachEventSourceHandlers(source as unknown as EventSource, {
onEvent(event) { received.push(event); throw new Error("consumer failed") },
onError() { reconnects++ },
logger: { warn() {}, error(message) { errors.push(message) } },
})
source.onmessage!(new MessageEvent("message", { data: "malformed" }))
source.onmessage!(new MessageEvent("message", { data: '{"type":"fixture"}' }))
assert.deepEqual(errors, ["Failed to parse event", "Failed to dispatch event"])
assert.deepEqual(received, [{ type: "fixture" }])
assert.equal(reconnects, 0)
})
it("requests reconnect when EventSource emits close", () => {
const source = new FakeEventSource()
let reconnects = 0

View file

@ -20,11 +20,19 @@ export function attachEventSourceHandlers(source: EventSource, options: EventSou
let disconnected = false
source.onmessage = (event) => {
let payload: WorkspaceEventPayload
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload
options.onEvent(payload)
payload = JSON.parse(event.data) as WorkspaceEventPayload
} catch (error) {
options.logger.error("Failed to parse event", error)
return
}
try {
options.onEvent(payload)
} catch (error) {
// Deferred reactive flush errors occur after individual subscribers have
// returned. They are application failures, not malformed transport data.
options.logger.error("Failed to dispatch event", error)
}
}

View file

@ -72,7 +72,7 @@ class ServerEvents {
}
logSse("Events stream connected")
this.retryDelay = RETRY_BASE_DELAY
this.openHandlers.forEach((handler) => handler())
this.notify(this.openHandlers, undefined, "open")
},
onPing: (payload) => {
if (generation !== this.connectGeneration) {
@ -149,8 +149,20 @@ class ServerEvents {
}
private dispatch(event: WorkspaceEventPayload) {
this.handlers.get("*")?.forEach((handler) => handler(event))
this.handlers.get(event.type)?.forEach((handler) => handler(event))
this.notify(this.handlers.get("*"), event, event.type)
this.notify(this.handlers.get(event.type), event, event.type)
}
private notify<T>(handlers: Set<(value: T) => void> | undefined, value: T, kind: string): void {
handlers?.forEach((handler) => {
try {
handler(value)
} catch (error) {
// A feature failure must not discard another feature's native event or
// interrupt the connection lifecycle. Do not replay failed handlers.
log.error("Backend event subscriber failed", { kind, error })
}
})
}
private dispatchBatch(events: WorkspaceEventPayload[]) {
@ -167,7 +179,7 @@ class ServerEvents {
}
private emitTransportStatus(status: WorkspaceEventTransportStatus) {
this.statusHandlers.forEach((handler) => handler(status))
this.notify(this.statusHandlers, status, "transport-status")
}
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {

View file

@ -0,0 +1,56 @@
import type { OpenCodeEvent } from "@opencode/client"
type CompactionDelta = Extract<OpenCodeEvent, { type: "session.compaction.delta" }>
const INTERVAL_MS = 250
// Only payload aggregation lives here. Revision/read authority remains with the
// native reducer owner, which cancels or flushes this buffer at its boundaries.
export function createCompactionDeltaBuffer<Context>(reduce: (event: CompactionDelta, context: Context) => void) {
const pending = new Map<string, {
event: CompactionDelta
context: Context
timeout: ReturnType<typeof setTimeout>
}>()
const keyFor = (instanceId: string, sessionId: string) => `${instanceId}\0${sessionId}`
const flush = (instanceId: string, sessionId: string) => {
const key = keyFor(instanceId, sessionId)
const item = pending.get(key)
if (!item) return
clearTimeout(item.timeout)
pending.delete(key)
reduce(item.event, item.context)
}
return {
flush,
push(instanceId: string, sessionId: string, event: CompactionDelta, context: Context) {
const key = keyFor(instanceId, sessionId)
const item = pending.get(key)
if (item) {
item.event = { ...event, data: { ...event.data, text: item.event.data.text + event.data.text } }
item.context = context
return
}
pending.set(key, {
event,
context,
timeout: setTimeout(() => flush(instanceId, sessionId), INTERVAL_MS),
})
},
cancel(instanceId: string, sessionId: string) {
const key = keyFor(instanceId, sessionId)
const item = pending.get(key)
if (!item) return
clearTimeout(item.timeout)
pending.delete(key)
},
clear(instanceId: string) {
const prefix = `${instanceId}\0`
for (const [key, item] of pending) {
if (!key.startsWith(prefix)) continue
clearTimeout(item.timeout)
pending.delete(key)
}
},
}
}

View file

@ -139,51 +139,6 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
const COMPACTION_PROJECTION_INTERVAL_MS = 250
const pendingCompactionProjections = new Map<string, {
timeout: ReturnType<typeof setTimeout>
project: () => void
}>()
function compactionProjectionKey(instanceId: string, sessionId: string): string {
return `${instanceId}\0${sessionId}`
}
function scheduleCompactionProjection(instanceId: string, sessionId: string, project: () => void): void {
const key = compactionProjectionKey(instanceId, sessionId)
const pending = pendingCompactionProjections.get(key)
if (pending) {
pending.project = project
return
}
const next = {
project,
timeout: setTimeout(() => {
pendingCompactionProjections.delete(key)
next.project()
}, COMPACTION_PROJECTION_INTERVAL_MS),
}
pendingCompactionProjections.set(key, next)
}
function cancelCompactionProjection(instanceId: string, sessionId: string): void {
const key = compactionProjectionKey(instanceId, sessionId)
const pending = pendingCompactionProjections.get(key)
if (!pending) return
clearTimeout(pending.timeout)
pendingCompactionProjections.delete(key)
}
function clearCompactionProjections(instanceId: string): void {
const prefix = `${instanceId}\0`
for (const [key, pending] of pendingCompactionProjections) {
if (!key.startsWith(prefix)) continue
clearTimeout(pending.timeout)
pendingCompactionProjections.delete(key)
}
}
// Interruption queues per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequest[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
@ -1218,7 +1173,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
clearSessionCatalogState(id)
clearCommands(id)
clearInstanceMetadata(id)
clearCompactionProjections(id)
destroyOpenCodeData(id)
volatileInstanceRefreshes.delete(id)
for (const sessionId of sessions().get(id)?.keys() ?? []) invalidateSessionMessageLoad(id, sessionId)
}
@ -1282,7 +1237,7 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) {
clearSettledForms(id)
clearPendingFormQueue(id)
clearInstanceMetadata(id)
clearCompactionProjections(id)
destroyOpenCodeData(id)
clearPermissionAutoAcceptForInstance(id)
clearSyncedYoloSessionsForInstance(id)
initialHydrations.delete(id)
@ -1953,10 +1908,6 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
? event.data.form.sessionID
: undefined
const isCompactionDelta = event.type === "session.compaction.delta"
if (event.type === "server.connected") clearCompactionProjections(instanceId)
if (sessionId && (event.type === "session.compaction.ended" || event.type === "session.compaction.failed")) {
cancelCompactionProjection(instanceId, sessionId)
}
const projectMessages = (data: ReturnType<typeof applyOpenCodeDataEvent>, preserveOmitted = true, force = false) => {
if (sessionId && event.type.startsWith("session.")
&& activeSessionId().get(instanceId) !== sessionId
@ -1964,7 +1915,6 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
invalidateSessionMessageLoad(instanceId, sessionId)
}
if (isCompactionDelta && !force) {
if (sessionId) scheduleCompactionProjection(instanceId, sessionId, () => projectMessages(data, preserveOmitted, true))
return
}
if (sessionId && (force || event.type.startsWith("session.")) && (
@ -1992,10 +1942,14 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters<NonNul
}
}
}
const data = applyOpenCodeDataEvent(instanceId, instance.folder, event, project, (next) => {
const data = applyOpenCodeDataEvent(instanceId, instance.folder, event, (next) => {
if (isCompactionDelta) projectMessages(next, true, true)
else project(next)
}, (next) => {
projectMessages(next, false, true)
schedulePendingRequestReconciliation(instanceId)
})
}, Boolean(sessionId && activeSessionId().get(instanceId) !== sessionId
&& !messageStoreBus.getInstance(instanceId)?.getSessionMessageIds(sessionId).length))
project(data)
if (sessionId && (event.type === "permission.asked" || event.type === "permission.replied")) {
const current = data.session.permission.list(sessionId) ?? []

View file

@ -0,0 +1,171 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sseManager } from "../lib/sse-manager.ts"
import {
applyOpenCodeDataEvent, destroyOpenCodeData, finishOpenCodeDataEvent,
getOpenCodeMessageRevision, invalidateOpenCodeSessionContent,
} from "./opencode-data.ts"
const model = { providerID: "fixture", id: "fixture" }
function fixture(instanceId: string) {
let sequence = 0, projections = 0
const client: any = {
session: { active: async () => ({}), get: async () => ({ id: "s" }),
inbox: { list: async () => [] }, form: { list: async () => [] } },
permission: { list: async () => [] },
message: { list: async () => ({ data: [], cursor: {} }) },
}
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
let data!: ReturnType<typeof applyOpenCodeDataEvent>
const emit = (type: string, payload: any = {}) => {
data = applyOpenCodeDataEvent(instanceId, "/fixture", {
type, id: `event-${++sequence}`, created: sequence,
data: { sessionID: "s", ...payload }, location: { directory: "/fixture" },
} as any, () => { projections++ })
return data
}
return {
emit, client, projections: () => projections,
data: () => data,
start: (id = "compact") => emit("session.compaction.started", { inputID: id, reason: "manual", recent: "" }),
delta: (text: string) => emit("session.compaction.delta", { text }),
summary: (id = "compact") => (data.session.message.get("s", id) as any)?.summary,
cleanup: () => { destroyOpenCodeData(instanceId); sdkManager.destroyClientsForInstance(instanceId) },
}
}
test("compaction chunks are fenced immediately, joined once, and preserve same-session order", ctx => {
ctx.mock.timers.enable({ apis: ["setTimeout"] })
const f = fixture("compaction-order")
try {
f.start()
const revision = getOpenCodeMessageRevision("compaction-order", "s")
for (let index = 0; index < 128; index++) f.delta(String(index))
assert.equal(getOpenCodeMessageRevision("compaction-order", "s"), revision + 128)
assert.equal(f.summary(), "")
assert.equal(f.projections(), 0)
ctx.mock.timers.tick(250)
assert.equal(f.summary(), Array.from({ length: 128 }, (_, i) => String(i)).join(""))
assert.equal(f.projections(), 1)
f.delta("prefix")
f.start("second")
assert(f.summary()!.endsWith("prefix"), "second start flushes preceding running compaction")
f.delta("second text")
ctx.mock.timers.tick(250)
assert.equal(f.summary("second"), "second text")
assert.equal(f.projections(), 3)
f.delta("pending")
f.emit("session.compaction.ended", { reason: "manual", text: "final", recent: "" })
ctx.mock.timers.tick(500)
assert.equal(f.summary("second"), "final")
assert.equal(f.projections(), 3, "ended supersedes buffered fragments without replay")
} finally { f.cleanup() }
})
for (const boundary of ["failed", "delete", "reconnect", "prune", "revert", "dispose"] as const) {
test(`pending compaction text cannot survive ${boundary}`, ctx => {
ctx.mock.timers.enable({ apis: ["setTimeout"] })
const instanceId = `compaction-${boundary}`
const f = fixture(instanceId)
try {
f.start()
f.delta("must not reappear")
if (boundary === "failed") f.emit("session.compaction.failed", { reason: "manual", error: { type: "fixture", message: "failed" } })
if (boundary === "delete") f.emit("session.deleted")
if (boundary === "reconnect") f.emit("server.connected")
if (boundary === "prune") invalidateOpenCodeSessionContent(instanceId, "s")
if (boundary === "revert") f.emit("session.revert.committed", { to: "compact" })
if (boundary === "dispose") destroyOpenCodeData(instanceId)
ctx.mock.timers.tick(500)
assert.equal(f.projections(), 0)
if (boundary === "revert" || boundary === "delete") assert.equal(f.summary(), undefined)
f.start("fresh")
f.delta("new")
ctx.mock.timers.tick(250)
if (boundary !== "prune") assert.equal(f.summary("fresh"), "new")
} finally { f.cleanup() }
})
}
test("idle flushes pending compaction before retiring its reducer", ctx => {
ctx.mock.timers.enable({ apis: ["setTimeout"] })
const f = fixture("compaction-idle")
try {
f.start()
f.delta("last text")
f.emit("session.idle")
assert.equal(f.summary(), "last text")
finishOpenCodeDataEvent("compaction-idle", { type: "session.idle", data: { sessionID: "s" } } as any)
ctx.mock.timers.tick(500)
assert.equal(f.projections(), 1)
} finally { f.cleanup() }
})
test("same session IDs in distinct instances keep separate buffers and flush authority", ctx => {
ctx.mock.timers.enable({ apis: ["setTimeout"] })
const first = fixture("compaction-instance-first"), second = fixture("compaction-instance-second")
try {
first.start(); second.start()
first.delta("A"); second.delta("B")
first.emit("session.usage.updated", { cost: 0, tokens: {} })
assert.equal(first.summary(), "A")
assert.equal(second.summary(), "")
ctx.mock.timers.tick(250)
assert.equal(second.summary(), "B")
assert.equal(first.projections(), 1)
assert.equal(second.projections(), 1)
} finally { first.cleanup(); second.cleanup() }
})
test("SDK history reads flush pending chunks and reject a pre-delta page without duplication", async () => {
const instanceId = "compaction-native-read"
const previous = sseManager.getStatuses
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
const f = fixture(instanceId)
const replies: Array<(value: any) => void> = []
const page = (summary: string) => ({ data: [{ id: "compact", type: "compaction", status: "running",
reason: "manual", summary, recent: "", model, time: { created: 1 } }], cursor: {} })
f.client.message.list = () => new Promise(resolve => replies.push(resolve))
try {
const data = f.start()
const read = data.session.message.sync("s")
await new Promise(resolve => setImmediate(resolve))
assert.equal(replies.length, 1)
f.delta("A")
replies[0](page(""))
await new Promise(resolve => setImmediate(resolve))
assert.equal(replies.length, 2, "admitted buffered delta fences stale SDK read")
replies[1](page("A"))
await read
assert.equal(f.summary(), "A", "authoritative page must not double-append buffered text")
f.delta("B")
data.session.message.invalidate("s")
const nextRead = data.session.message.sync("s")
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.summary(), "AB", "pending text is reduced before a new native read")
replies[2](page("AB"))
await nextRead
assert.equal(f.summary(), "AB")
assert.equal(f.projections(), 2)
} finally { f.cleanup(); sseManager.getStatuses = previous }
})
test("rotation at the 200-message boundary retains exactly one running compaction and its pending chunks", async () => {
const f = fixture("compaction-rotation")
try {
for (let index = 0; index < 200; index++) {
f.emit("session.step.started", { assistantMessageID: `m${index}`, agent: "build", model })
f.emit("session.step.ended", { assistantMessageID: `m${index}`, finish: "stop" })
}
f.start()
f.delta("A"); f.delta("B")
// Same-session terminal delivery forces native order while the start is
// still queued behind the bounded SDK snapshot rotation.
f.emit("session.usage.updated", { cost: 0, tokens: {} })
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.summary(), "AB")
assert.equal(f.data().session.message.list("s").length, 200)
assert.equal(f.data().session.message.list("s").filter(message => message.type === "compaction").length, 1)
} finally { f.cleanup() }
})

View file

@ -8,12 +8,14 @@ import { MESSAGE_WINDOW_PAGE_SIZE } from "./message-v2/message-window"
import { messageStoreBus } from "./message-v2/bus"
import { sseManager } from "../lib/sse-manager"
import { getLogger } from "../lib/logger"
import { createCompactionDeltaBuffer } from "./compaction-delta-buffer"
const log = getLogger("session")
type DataEntry = {
data: Data
emit: (event: OpenCodeEvent) => void
fencePendingReads: (sessionId: string) => void
syncMessages: (sessionId: string, messages: SessionMessageInfo[], isCurrent: () => boolean) => Promise<boolean>
syncAuthoritative: (sessionId: string, isCurrent: () => boolean) => Promise<boolean>
dispose: () => void
@ -57,6 +59,18 @@ const messageRevisions = new Map<string, number>()
const fullDataRevisions = new Map<string, number>()
const instanceGenerations = new Map<string, number>()
const instanceDataRevisions = new Map<string, ReturnType<typeof createSignal<number>>>()
const unobservedCompactions = new Set<string>()
type CompactionDeltaContext = {
instanceId: string
directory: string
onDeferred?: (data: Data) => void
onResynced?: (data: Data) => void
}
const coalescedCompactionEvents = new WeakSet<object>()
const compactionDeltas = createCompactionDeltaBuffer<CompactionDeltaContext>((event, context) => {
coalescedCompactionEvents.add(event)
applyOpenCodeDataEvent(context.instanceId, context.directory, event, context.onDeferred, context.onResynced)
})
let nextInstanceGeneration = 0
function messageRevisionKey(instanceId: string, sessionId: string): string {
@ -130,6 +144,10 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
throw new Error("Stale read from disposed OpenCode projection")
}
// A current native page can already contain buffered text.
// Reduce it before taking read authority, never append it
// again to a page that was fetched after delta admission.
compactionDeltas.flush(instanceId, input.sessionID)
const snapshot = messageSnapshots.get(input.sessionID)
if (snapshot) return { data: [...snapshot].reverse(), cursor: {} }
const revision = eventRevisions.get(input.sessionID) ?? 0
@ -137,6 +155,7 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
if (disposed || getOpenCodeInstanceGeneration(instanceId) !== instanceGeneration) {
throw new Error("Stale read from disposed OpenCode projection")
}
compactionDeltas.flush(instanceId, input.sessionID)
if ((eventRevisions.get(input.sessionID) ?? 0) !== revision) continue
return response
}
@ -167,6 +186,9 @@ function createDataEntry(instanceId: string, directory: string): DataEntry {
emit(details: OpenCodeEvent) {
emit(details)
},
fencePendingReads(sessionId: string) {
eventRevisions.set(sessionId, (eventRevisions.get(sessionId) ?? 0) + 1)
},
async syncMessages(sessionId: string, messages: SessionMessageInfo[], isCurrent: () => boolean) {
messageSnapshots.set(sessionId, messages)
try {
@ -573,6 +595,7 @@ export function applyOpenCodeDataEvent(
event: OpenCodeEvent,
onDeferred?: (data: Data) => void,
onResynced?: (data: Data) => void,
skipUnobservedCompaction = false,
): Data {
if (event.type === "server.connected") destroyOpenCodeData(instanceId)
const primary = ensureData(instanceId, directory)
@ -581,6 +604,20 @@ export function applyOpenCodeDataEvent(
// with sync:false, feeding these events to createData starts HTTP refreshes.
if (!/^(session|permission|form)\./.test(event.type)) return primary.data
const sessionId = eventSessionId(event)
const coalesced = coalescedCompactionEvents.delete(event)
if (typeof sessionId === "string" && !coalesced) {
if (event.type === "session.compaction.ended" || event.type === "session.compaction.failed"
|| event.type === "session.deleted" || event.type === "session.revert.committed") {
// Terminal events replace summary text. Destructive events must not let
// a delayed fragment resurrect a removed or invalidated compaction.
compactionDeltas.cancel(instanceId, sessionId)
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
} else if (event.type !== "session.compaction.delta") {
// A second start or another same-session event observes preceding text
// in native order; unrelated sessions never force a flush.
compactionDeltas.flush(instanceId, sessionId)
}
}
if (event.type === "session.deleted" && typeof sessionId === "string") {
const key = messageRevisionKey(instanceId, sessionId)
const transcript = transcriptEntries.get(key)
@ -595,24 +632,49 @@ export function applyOpenCodeDataEvent(
fullDataRevisions.delete(key)
return transcript?.entry.data ?? primary.data
}
if (typeof sessionId === "string" && !coalesced) {
const key = messageRevisionKey(instanceId, sessionId)
fullDataRevisions.set(key, (fullDataRevisions.get(key) ?? 0) + 1)
if (eventAffectsMessages(event)) messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
if (event.type === "session.inbox.cancelled" || event.type === "session.revert.committed") bumpMutationRevision(key)
if (skipUnobservedCompaction && event.type.startsWith("session.compaction.") && !transcriptEntries.has(key)) {
// No native reducer/visible transcript is observing this session. The
// next activation loads authoritative messages instead of storing deltas.
if (event.type === "session.compaction.started") unobservedCompactions.add(key)
if (event.type === "session.compaction.ended" || event.type === "session.compaction.failed") unobservedCompactions.delete(key)
return primary.data
}
}
const transcript = typeof sessionId === "string"
? ensureTranscript(instanceId, sessionId, directory)
: undefined
const entry = transcript?.entry ?? primary
if (transcript && typeof sessionId === "string") {
if (event.type === "session.compaction.started") unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
// A new execution must not inherit a preceding idle event's deferred cleanup.
if (event.type === "session.execution.started" || eventMayAppendMessage(event)) transcript.retireWhenDrained = false
const key = messageRevisionKey(instanceId, sessionId)
fullDataRevisions.set(key, (fullDataRevisions.get(key) ?? 0) + 1)
if (eventAffectsMessages(event)) messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
if (event.type === "session.inbox.cancelled" || event.type === "session.revert.committed") bumpMutationRevision(key)
}
if (transcript && typeof sessionId === "string") {
if (onResynced) transcript.onResynced = onResynced
if (event.type === "session.compaction.delta" && unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))) {
// Activation during a previously unobserved compaction has no start row
// in the SDK reducer. Recover its exact native summary instead of making
// up a start or appending a delta to a REST page that already includes it.
transcript.preserveNativePageOnResync = true
collapseTranscriptQueue(instanceId, sessionId, transcript)
return transcript.entry.data
}
if (transcript.needsAuthoritativeResync || transcript.resyncing) {
collapseTranscriptQueue(instanceId, sessionId, transcript)
return transcript.entry.data
}
if (event.type === "session.compaction.delta" && !coalesced) {
// Admission fences both CodeNomad and SDK reads immediately, even though
// the reactive payload is reduced only once per interval.
entry.fencePendingReads(sessionId)
compactionDeltas.push(instanceId, sessionId, event, { instanceId, directory, onDeferred, onResynced })
return entry.data
}
if (transcript.rotating
|| (eventMayAppendMessage(event, transcript.entry.data.session.message.list(sessionId)
.filter((message) => message.type === "compaction" && message.status === "running").length)
@ -623,6 +685,7 @@ export function applyOpenCodeDataEvent(
}
}
entry.emit(event)
if (coalesced) onDeferred?.(entry.data)
return entry.data
}
@ -631,6 +694,8 @@ export function getOpenCodeMessageRevision(instanceId: string, sessionId: string
}
export function invalidateOpenCodeSessionContent(instanceId: string, sessionId: string): void {
compactionDeltas.cancel(instanceId, sessionId)
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
const key = messageRevisionKey(instanceId, sessionId)
bumpMutationRevision(key)
messageRevisions.set(key, (messageRevisions.get(key) ?? 0) + 1)
@ -720,6 +785,8 @@ export function finishOpenCodeDataEvent(instanceId: string, event: OpenCodeEvent
export function destroyOpenCodeData(instanceId: string, sessionId?: string): void {
if (sessionId !== undefined) {
compactionDeltas.cancel(instanceId, sessionId)
unobservedCompactions.delete(messageRevisionKey(instanceId, sessionId))
const key = messageRevisionKey(instanceId, sessionId)
const transcript = transcriptEntries.get(key)
if (transcript) {
@ -734,6 +801,10 @@ export function destroyOpenCodeData(instanceId: string, sessionId?: string): voi
instanceDataRevision(instanceId)[1]((current) => current + 1)
return
}
compactionDeltas.clear(instanceId)
for (const key of unobservedCompactions) {
if (key.startsWith(`${instanceId}\0`)) unobservedCompactions.delete(key)
}
instanceGenerations.set(instanceId, ++nextInstanceGeneration)
entries.get(instanceId)?.dispose()
entries.delete(instanceId)

View file

@ -0,0 +1,145 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { readFile } from "node:fs/promises"
import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), {
name: "compaction-counts",
enforce: "pre",
async transform(source, id) {
if (id.endsWith("/stores/instances.ts") && process.env.CODENOMAD_COMPACTION_BASELINE) {
return readFile(`${process.env.CODENOMAD_COMPACTION_BASELINE}/instances.ts`, "utf8")
}
if (!id.endsWith("/stores/opencode-data.ts")) return
if (process.env.CODENOMAD_COMPACTION_BASELINE) source = await readFile(`${process.env.CODENOMAD_COMPACTION_BASELINE}/opencode-data.ts`, "utf8")
// Observe the actual native reducer boundary, not a reimplemented reducer.
return source.replace("for (const listener of listeners)", `
if (details.type === "session.compaction.delta") {
const counts = (window as any).compactionCounts?.deltas
if (counts) counts[details.data.sessionID] = (counts[details.data.sessionID] ?? 0) + 1
}
for (const listener of listeners)`)
.replace("const source = data.session.message.list(sessionId)", `
const counts = (window as any).compactionCounts?.projections
if (counts) counts[sessionId] = (counts[sessionId] ?? 0) + 1
const source = data.session.message.list(sessionId)`)
},
configureServer(vite) {
vite.middlewares.use("/compaction", async (_req, res) => {
res.setHeader("Content-Type", "text/html")
res.end(await vite.transformIndexHtml("/compaction", '<html><body><div id="root" style="display:flex;width:1100px;height:740px"></div><script type="module" src="/tests/browser/fixtures/compaction-responsiveness.tsx"></script></body></html>'))
})
},
}], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/compaction`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await server?.close() })
async function fixture(run: (page: Page) => Promise<void>) {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
await page.route("**/*", route => {
const request = new URL(route.request().url())
if (request.hostname !== "127.0.0.1") return route.abort()
if (request.pathname.startsWith("/api/")) return route.fulfill({ json: {} })
return route.continue()
})
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 })
await page.waitForFunction(() => Boolean((window as any).compactionFixture), undefined, { timeout: 30_000 })
await page.waitForTimeout(300)
await run(page)
assert.deepEqual(errors, [])
} finally { await page.close() }
}
const snapshot = (page: Page) => page.evaluate(() => (window as any).compactionFixture.snapshot())
const joined = (count: number) => Array.from({ length: count }, (_, index) => `[${index}]`).join("")
test("128 active deltas reduce and project once, preserve immediate revision, and end authoritatively", () => fixture(async page => {
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.resetCounts() })
const before = await snapshot(page)
const dispatchMs = await page.evaluate(() => (window as any).compactionFixture.burst("active"))
const pending = await snapshot(page)
console.log(JSON.stringify({ scenario: "active128-ingress", dispatchMs, counts: pending.counts }))
assert.equal(pending.revision - before.revision, 128)
assert.equal(pending.counts.deltas.active ?? 0, 0)
assert.equal(pending.counts.projections.active ?? 0, 0)
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active?.length > 0)
const flushed = await snapshot(page)
assert.equal(flushed.active, joined(128))
assert.equal(flushed.counts.deltas.active, 1)
assert.equal(flushed.counts.projections.active, 1)
assert.equal(flushed.messageLists, 0)
console.log(JSON.stringify({ scenario: "active128", dispatchMs, counts: flushed.counts }))
await page.evaluate(() => { const f = (window as any).compactionFixture; f.burst("active", 4); f.end("active", "authoritative final") })
await page.waitForTimeout(300)
const final = await snapshot(page)
assert.equal(final.active, "authoritative final")
assert.equal(final.counts.deltas.active, 1, "terminal final text cancels pending fragments")
}))
test("spaced native events coalesce per interval without losing chunks or affecting the other session", () => fixture(async page => {
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.resetCounts() })
for (let phase = 1; phase <= 2; phase++) {
await page.evaluate(() => (window as any).compactionFixture.phase("active"))
await page.waitForFunction(phase => (window as any).compactionFixture.snapshot().active ===
Array.from({ length: 8 }, (_, index) => `[${index}]`).join("").repeat(phase), phase)
const state = await snapshot(page)
assert.equal(state.counts.deltas.active, phase)
assert.equal(state.counts.projections.active, phase)
assert.equal(state.messageLists, 0)
}
}))
test("never-loaded inactive compaction allocates no reducer payload; activation and remount hydrate exact final text", () => fixture(async page => {
await page.evaluate(() => { const f = (window as any).compactionFixture; f.resetCounts(); f.start("inactive"); f.burst("inactive"); f.end("inactive", "saved summary") })
await page.waitForTimeout(300)
const inactive = await snapshot(page)
assert.deepEqual(inactive.counts, { deltas: {}, projections: {} })
assert.equal(inactive.inactive, undefined)
assert.equal(inactive.messageLists, 0)
await page.evaluate(() => (window as any).compactionFixture.activate("inactive"))
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "saved summary")
await page.evaluate(() => (window as any).compactionFixture.unmount())
await page.evaluate(() => (window as any).compactionFixture.remount())
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "saved summary")
assert.equal((await snapshot(page)).active, undefined)
}))
test("activation during an unobserved compaction hydrates then resumes exact streaming summary", () => fixture(async page => {
await page.evaluate(() => { const f = (window as any).compactionFixture; f.seed("inactive", 199); f.start("inactive"); f.burst("inactive", 4); f.activate("inactive") })
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3]")
await page.evaluate(() => (window as any).compactionFixture.burst("inactive", 2))
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3][0][1]")
await page.evaluate(() => { const f = (window as any).compactionFixture; f.resetCounts(); f.burst("inactive", 2) })
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().inactive === "[0][1][2][3][0][1][0][1]")
const resumed = await snapshot(page)
assert.equal(resumed.counts.deltas.inactive, 1)
assert.equal(resumed.counts.projections.inactive, 1)
assert.equal(resumed.messageLists, 0)
assert.equal(resumed.ids.length, 200, "SDK's 20-row recovery must retain the hydrated native 200-row window")
}))
test("a loaded but inactive reducer batches text, does not hydrate its hidden view, and returns without duplication", () => fixture(async page => {
await page.evaluate(() => { const f = (window as any).compactionFixture; f.start(); f.activate("inactive"); f.resetCounts(); f.burst("active", 4) })
await page.waitForTimeout(300)
const hidden = await snapshot(page)
assert.equal(hidden.counts.deltas.active, 1)
assert.equal(hidden.counts.projections.active ?? 0, 0)
assert.equal(hidden.active, "", "hidden visible snapshot is revalidated, not hydrated by every delta")
await page.evaluate(() => (window as any).compactionFixture.activate("active"))
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active === "[0][1][2][3]")
await page.evaluate(() => (window as any).compactionFixture.burst("active", 2))
await page.waitForFunction(() => (window as any).compactionFixture.snapshot().active === "[0][1][2][3][0][1]")
}))

View file

@ -0,0 +1,140 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import Fastify, { type FastifyInstance } from "fastify"
import pino from "pino"
import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import { EventBus } from "../../../server/src/events/bus"
import { ClientConnectionManager } from "../../../server/src/clients/connection-manager"
import { registerEventRoutes } from "../../../server/src/server/routes/events"
let backend: FastifyInstance, server: ViteDevServer, browser: Browser, connections: ClientConnectionManager, url: string
const bus = new EventBus()
const clients = new Set<() => void>()
before(async () => {
const logger = pino({ level: "silent" })
// Send a body frame immediately so the Vite proxy forwards idle stream headers.
bus.publish({ type: "instance.eventStatus", instanceId: "fixture", status: "connected", generation: 1 })
connections = new ClientConnectionManager(logger)
backend = Fastify()
registerEventRoutes(backend, { eventBus: bus, logger, connectionManager: connections,
registerClient: close => { clients.add(close); return () => { clients.delete(close) } },
})
backend.get("/api/auth/status", async () => ({ authenticated: true }))
await backend.listen({ host: "127.0.0.1", port: 0 })
const target = `http://127.0.0.1:${(backend.server.address() as { port: number }).port}`
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
plugins: [solid(), { name: "event-subscriber-isolation", configureServer(s) {
s.middlewares.use("/fixture", async (_request, response) => {
response.setHeader("Content-Type", "text/html")
response.end(await s.transformIndexHtml("/fixture", '<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/event-subscriber-isolation.tsx"></script></body></html>'))
})
} }], resolve: { dedupe: ["solid-js"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null, proxy: { "/api": { target } } },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => {
await browser?.close()
connections?.shutdown()
for (const close of clients) close()
await server?.close()
await backend?.close()
})
function emit(id: string) {
bus.publish({ type: "instance.event", instanceId: "fixture", event: {
id, created: Date.now(), type: "session.renamed", data: { sessionID: "session", title: id },
durable: { aggregateID: "session", seq: 1, version: 1 }, location: { directory: "/fixture" },
} })
}
async function setup(page: Page, mode: string) {
const pageErrors: string[] = []
page.on("pageerror", error => pageErrors.push(error.message))
await page.goto(`${url}?mode=${mode}`)
await page.locator("[data-events]").waitFor()
await page.waitForFunction(() => (window as any).fixture.snapshot().statuses.includes("connected"))
return pageErrors
}
test("a failing wildcard subscriber cannot discard a valid SSE event before native and typed subscribers", async () => {
const page = await browser.newPage()
try {
const pageErrors = await setup(page, "event")
emit("first")
emit("second")
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 2)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.deepEqual(state.native, ["first", "second"], "real SSEManager must receive both events despite another subscriber's error")
assert.deepEqual(state.typed, ["first", "second"])
assert.equal(state.errors.length, 2, "each failed callback is diagnosed, without replaying the event")
assert.doesNotMatch(JSON.stringify(state.errors), /Failed to parse event/)
assert.deepEqual(pageErrors, [])
} finally { await page.close() }
})
test("a deferred Solid derivation error is diagnosed as dispatch, not malformed SSE, without replay", async () => {
const page = await browser.newPage()
try {
const pageErrors = await setup(page, "derived")
emit("first")
await page.waitForFunction(() => (window as any).fixture.snapshot().errors.length === 1)
emit("second")
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 2)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.deepEqual(state.native, ["first", "second"])
assert.deepEqual(state.typed, ["first", "second"])
assert.equal(state.errors[0][0], "Failed to dispatch event")
assert.doesNotMatch(JSON.stringify(state.errors), /Failed to parse event/)
assert.deepEqual(pageErrors, [])
} finally { await page.close() }
})
test("a failing open subscriber cannot suppress later reconnect reconciliation subscribers", async () => {
const page = await browser.newPage()
try {
const pageErrors = await setup(page, "open")
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 1)
assert.equal(await page.evaluate(() => (window as any).fixture.snapshot().opens), 1)
assert.equal(await page.evaluate(() => (window as any).fixture.restart()), null)
await page.waitForFunction(() => (window as any).fixture.snapshot().faults === 2)
assert.equal(await page.evaluate(() => (window as any).fixture.snapshot().opens), 2)
assert.deepEqual(pageErrors, [])
} finally { await page.close() }
})
test("a failing status subscriber cannot abort restart or prevent later transport-status notifications", async () => {
const page = await browser.newPage()
try {
const pageErrors = await setup(page, "status")
assert.equal(await page.evaluate(() => (window as any).fixture.restart()), null, "restart must still close and replace its connection")
await page.waitForFunction(() => (window as any).fixture.snapshot().opens === 2)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.ok(state.statuses.includes("disconnected"))
emit("after-restart")
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 1)
assert.deepEqual(await page.evaluate(() => (window as any).fixture.snapshot().native), ["after-restart"])
assert.deepEqual(pageErrors, [])
} finally { await page.close() }
})
test("normal subscribers retain SSE FIFO and unsubscribe removes only its own listener", async () => {
const page = await browser.newPage()
try {
const pageErrors = await setup(page, "normal")
emit("first")
await page.waitForFunction(() => (window as any).fixture.snapshot().typed.length === 1)
await page.evaluate(() => (window as any).fixture.unsubscribe())
emit("second")
await page.waitForFunction(() => (window as any).fixture.snapshot().native.length === 2)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.deepEqual(state.native, ["first", "second"])
assert.deepEqual(state.typed, ["first"])
assert.deepEqual(state.errors, [])
assert.deepEqual(pageErrors, [])
} finally { await page.close() }
})

View file

@ -0,0 +1,103 @@
import { createSignal, Show } from "solid-js"
import { render } from "solid-js/web"
import SessionView from "../../../src/components/session/session-view"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import { sdkManager } from "../../../src/lib/sdk-manager"
import { addInstance } from "../../../src/stores/instances"
import { sessions, setSessions, setActiveSession, setProviders } from "../../../src/stores/session-state"
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
import { sseManager } from "../../../src/lib/sse-manager"
import { getOpenCodeMessageRevision } from "../../../src/stores/opencode-data"
import "../../../src/index.css"
const instanceId = "compaction-fixture", model = { providerID: "fixture", id: "fixture" }
let sequence = 1000, messageLists = 0
const nativeMessages = new Map<string, any[]>()
const [selected, select] = createSignal("active")
const [visible, show] = createSignal(true)
const client: any = {
session: {
active: async () => ({}), inbox: { list: async () => [] },
form: { list: async () => [] },
get: async ({ sessionID }: any) => ({ id: sessionID, title: sessionID,
location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
instructions: { entry: { remove: async () => {}, put: async () => {} } },
},
model: { default: async () => ({ data: model }) },
permission: { list: async () => [] },
message: { list: async ({ sessionID, limit = 200 }: any) => {
messageLists++
return { data: structuredClone((nativeMessages.get(sessionID) ?? []).slice(-limit)).reverse(), cursor: {} }
} },
}
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setSessions(previous => new Map(previous).set(instanceId, new Map(["active", "inactive"].map(id => [id, {
id, instanceId, parentId: null, title: id, agent: "build", model: { providerId: "fixture", modelId: "fixture" },
status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true,
version: "1", projectID: "fixture", location: { directory: "/fixture" }, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 },
}]))))
setProviders(previous => new Map(previous).set(instanceId, [{ id: "fixture", name: "Fixture", models: [
{ id: "fixture", name: "Fixture", providerId: "fixture", limit: { context: 10000, output: 1000 }, cost: { input: 0, output: 0 } },
] }]))
setActiveSession(instanceId, "active")
const store = messageStoreBus.getOrCreate(instanceId)
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}>
<SessionView sessionId={selected()} activeSessions={sessions().get(instanceId)!} instanceId={instanceId}
instanceFolder="/fixture" escapeInDebounce={false} isActive={true} />
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
const emit = (sessionID: string, type: string, data: any = {}) => {
if (type === "session.compaction.started") nativeMessages.set(sessionID, [...(nativeMessages.get(sessionID) ?? []), { id: `compact-${sessionID}`,
type: "compaction", status: "running", reason: "manual", summary: "", recent: "", time: { created: sequence } }])
if (type === "session.compaction.delta") nativeMessages.get(sessionID)!.at(-1).summary += data.text
;(sseManager as any).handleEvent(instanceId, {
id: `event_${++sequence}`, type, created: sequence, location: { directory: "/fixture" }, data: { sessionID, ...data },
})
}
const text = (sessionID: string) => {
const message = store.getMessage(`compact-${sessionID}`)
return message?.partIds.map(id => (message.parts[id].data as any).text).join("")
}
;(window as any).compactionFixture = {
resetCounts: () => { (window as any).compactionCounts = { deltas: {}, projections: {} }; messageLists = 0 },
seed: (sessionID: string, count: number) => nativeMessages.set(sessionID, Array.from({ length: count }, (_, index) => ({
id: `history-${sessionID}-${String(index).padStart(4, "0")}`, type: "user", text: `History ${index}`, time: { created: index },
}))),
snapshot: () => ({ counts: (window as any).compactionCounts, messageLists,
active: text("active"), inactive: text("inactive"), ids: store.getSessionMessageIds(selected()),
revision: getOpenCodeMessageRevision(instanceId, selected()) }),
start: (sessionID = "active") => {
emit(sessionID, "session.compaction.started", { inputID: `compact-${sessionID}`, reason: "manual", recent: "" })
},
burst: (sessionID: string, count = 128) => {
const started = performance.now()
for (let index = 0; index < count; index++) emit(sessionID, "session.compaction.delta", { text: `[${index}]` })
return performance.now() - started
},
phase: async (sessionID: string) => {
for (let index = 0; index < 8; index++) {
emit(sessionID, "session.compaction.delta", { text: `[${index}]` })
await new Promise(resolve => setTimeout(resolve, 10))
}
},
end: (sessionID: string, summary: string) => {
nativeMessages.set(sessionID, [{ id: `compact-${sessionID}`, type: "compaction", status: "completed",
reason: "manual", summary, recent: "", model, time: { created: 1 } }])
emit(sessionID, "session.compaction.ended", { reason: "manual", text: summary, recent: "", model })
},
fail: (sessionID: string) => emit(sessionID, "session.compaction.failed", {
inputID: `compact-${sessionID}`, reason: "manual", error: { type: "fixture", message: "cancelled" },
}),
activate: (sessionID: string) => {
show(false)
select(sessionID)
setActiveSession(instanceId, sessionID)
show(true)
},
unmount: () => show(false),
remount: () => show(true),
}

View file

@ -0,0 +1,44 @@
import { createMemo, createRoot, createSignal } from "solid-js"
import { render } from "solid-js/web"
import { serverEvents } from "../../../src/lib/server-events"
import { sseManager } from "../../../src/lib/sse-manager"
import { getLogger } from "../../../src/lib/logger"
const mode = new URLSearchParams(location.search).get("mode")
const native: string[] = [], typed: string[] = [], statuses: string[] = [], errors: unknown[][] = []
let faults = 0, opens = 0
const [version, setVersion] = createSignal(0)
const changed = () => setVersion(value => value + 1)
const fail = () => { faults++; changed(); throw new Error("synthetic subscriber failure") }
// Replace diagnostics only; dispatch, transport, native reducer entry and the
// browser's EventSource are production implementations.
getLogger("sse").error = (...args) => { errors.push(args) }
if (mode === "event") serverEvents.on("*", event => { if (event.type === "instance.event") fail() })
if (mode === "derived") createRoot(() => {
const [broken, setBroken] = createSignal(false)
createMemo(() => { if (broken()) throw new Error("synthetic deferred derivation failure") })
serverEvents.on("*", event => {
if (event.type !== "instance.event") return
faults++
changed()
setBroken(true)
})
})
if (mode === "open") serverEvents.onOpen(fail)
if (mode === "status") serverEvents.onTransportStatus(status => { if (status === "disconnected") fail() })
sseManager.onNativeSessionEvent = (_id, event) => { native.push(event.id); changed() }
const unsubscribe = serverEvents.on("instance.event", event => {
if (event.type === "instance.event") typed.push(event.event.id)
changed()
})
serverEvents.onOpen(() => { opens++; changed() })
serverEvents.onTransportStatus(status => { statuses.push(status); changed() })
;(window as any).fixture = {
snapshot: () => ({ native, typed, statuses, faults, opens, errors }),
restart: () => {
try { serverEvents.restart("isolated subscriber fixture"); return null }
catch (error) { return error instanceof Error ? error.message : String(error) }
},
unsubscribe,
}
render(() => <pre data-events>{version()}{JSON.stringify({ native, typed, statuses, faults, opens })}</pre>, document.getElementById("root")!)

View file

@ -0,0 +1,52 @@
import { OpenCode } from "@opencode/client"
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import { createInstanceFetch } from "../../../src/lib/sdk-manager"
import { serverEvents } from "../../../src/lib/server-events"
// Deliberately small transport fixture, not a replacement session store/UI.
// All API requests and SSE use the real production browser adapters.
const baseUrl = `${location.origin}/workspaces/workspace/instance/`
const client = OpenCode.make({ baseUrl, fetch: createInstanceFetch(baseUrl) })
const [selected, setSelected] = createSignal("A")
const [loaded, setLoaded] = createSignal("")
const [draft, setDraft] = createSignal("")
const [opens, setOpens] = createSignal(0)
const [events, setEvents] = createSignal(0)
const [pending, setPending] = createSignal(false)
const errors: string[] = []
let catalogueSettled = 0
serverEvents.onOpen(() => setOpens(value => value + 1))
serverEvents.on("instance.eventStatus", () => setEvents(value => value + 1))
const fixture = {
start(action: "prompt" | "compact") {
setPending(true)
const operation = action === "prompt"
? client.session.prompt({ sessionID: "A", text: "private transport fixture" })
: client.session.compact({ sessionID: "A" })
void operation.catch(error => errors.push(String(error))).finally(() => setPending(false))
},
catalogues() {
// Five distinct catalogue intents, not foreground session reads: production
// createInstanceFetch must admit only two and leave the other three queued.
// Distinct URLs avoid Chromium's same-URL cache request coalescing.
for (let index = 0; index < 5; index++) {
void client.agent.get({ agentID: `fixture-${index}` }).catch(error => errors.push(String(error))).finally(() => catalogueSettled++)
}
},
async info() { return (await fetch("/api/meta")).json() },
async witness() { return (await fetch("/api/witness")).json() },
snapshot() { return { selected: selected(), loaded: loaded(), draft: draft(), opens: opens(), events: events(), pending: pending(), catalogueSettled, errors } },
}
;(window as any).fixture = fixture
render(() => <main>
<input aria-label="Draft" value={draft()} onInput={event => setDraft(event.currentTarget.value)} />
<button onClick={() => {
setSelected("B")
void client.session.get({ sessionID: "B" }).then(session => setLoaded(session.id)).catch(error => errors.push(String(error)))
}}>Switch to B</button>
<output data-selected>{selected()}</output>
<output data-loaded>{loaded()}</output>
</main>, document.getElementById("root")!)

View file

@ -0,0 +1,44 @@
import { createSignal } from "solid-js"
import { render } from "solid-js/web"
import MessageTimeline, { type TimelineSegment } from "../../../src/components/message-timeline"
import { I18nProvider } from "../../../src/lib/i18n"
import { ConfigProvider } from "../../../src/stores/preferences"
import { ThemeProvider } from "../../../src/lib/theme"
import { applyUiSettings } from "./ui-settings"
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
import { serverApi } from "../../../src/lib/api-client"
import "../../../src/index.css"
const count = Math.min(50_000, Math.max(10, Number(new URLSearchParams(location.search).get("count") ?? 10_000)))
const segments: TimelineSegment[] = Array.from({ length: count }, (_, index) => ({
id: `marker-${index}`, messageId: `message-${Math.floor(index / 3)}`, type: index % 3 === 0 ? "user" : index % 3 === 1 ? "tool" : "assistant",
label: String(index), tooltip: `Marker ${index}`, toolName: index % 3 === 1 ? "read" : undefined,
toolPartIds: index % 3 === 1 ? [`tool-${index}`] : undefined, totalChars: 0,
}))
// Seed one resident preview; all other excerpts are deterministic HTTP-boundary
// answers, never transcript loads. This also exercises Markdown in the tooltip.
const store = messageStoreBus.getOrCreate("render-timeline")
store.upsertMessage({ id: "message-0", sessionId: "timeline", role: "user", status: "complete", createdAt: 1,
parts: [{ id: "preview-text", type: "text", text: "**Resident preview**\n\n" + "bounded excerpt ".repeat(1000) }] })
const requests: number[] = []
serverApi.fetchOutlinePreviews = async (_instance, _session, ids) => {
requests.push(ids.length)
return { status: "previews", entries: ids.map(id => ({ id, text: "**Local excerpt**", tools: "Read tool excerpt" })) } as any
}
const [active, setActive] = createSignal("marker-0")
let selected = "", activeCounter = 0
await applyUiSettings({ locale: "en" })
const start = performance.now()
render(() => <ConfigProvider><I18nProvider><ThemeProvider><div class="message-timeline-sidebar" style={{ height: "600px", width: "60px" }}>
<MessageTimeline instanceId="render-timeline" sessionId="timeline" segments={segments}
activeSegmentId={active()} onSegmentClick={segment => { selected = segment.id }} />
</div></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
const mountMs = performance.now() - start
;(window as any).fixture = {
measure: (updates: number) => {
const start = performance.now()
for (let index = 0; index < updates; index++) setActive(`marker-${++activeCounter % 15}`)
return performance.now() - start
},
snapshot: () => ({ count, selected, active: active(), requests, mountMs }),
}

View file

@ -0,0 +1,157 @@
import { batch } from "solid-js"
import { render } from "solid-js/web"
import { ConfigProvider } from "../../../src/stores/preferences"
import { I18nProvider } from "../../../src/lib/i18n"
import { ThemeProvider } from "../../../src/lib/theme"
import MessageSection from "../../../src/components/message-section"
import { applyUiSettings } from "./ui-settings"
import { sdkManager } from "../../../src/lib/sdk-manager"
import { addInstance } from "../../../src/stores/instances"
import { setSessions, setActiveSession } from "../../../src/stores/session-state"
import { sseManager } from "../../../src/lib/sse-manager"
import { loadMessages } from "../../../src/stores/session-api"
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
import "../../../src/index.css"
const params = new URLSearchParams(location.search)
const mode = params.get("mode") ?? "task"
const count = Math.min(1000, Math.max(1, Number(params.get("count") ?? 12)))
const outputSize = Math.min(2_000_000, Math.max(1000, Number(params.get("size") ?? 8000)))
const instanceId = "render-cost", parentId = "parent", childId = "child"
const model = { providerID: "fixture", id: "fixture" }
const output = Array.from({ length: Math.ceil(outputSize / "Output 00000 with deterministic text\n".length) }, (_, index) => `Output ${String(index).padStart(5, "0")} with deterministic text\n`).join("").slice(0, outputSize)
const tool = (index: number) => ({ id: `step-${index}`, type: "tool", name: "read",
time: { created: index + 1, completed: index + 2 },
state: { status: "completed", input: { filePath: `file-${index}.txt` }, metadata: {}, content: [{ type: "text", text: output }] } })
const history: Record<string, any[]> = mode === "task" ? {
[parentId]: [{ id: "parent-message", type: "assistant", agent: "build", model, time: { created: 1, completed: 2 },
content: [{ id: "parent-task", type: "tool", name: "subagent", time: { created: 1, completed: 2 },
state: { status: "completed", input: { agent: "explore", description: "Deterministic child" }, metadata: { sessionId: childId }, content: [{ type: "text", text: "Task result" }] } }] }],
[childId]: [{ id: "child-message", type: "assistant", agent: "build", model, time: { created: 3 },
content: Array.from({ length: count }, (_, index) => tool(index)) }],
} : {
[parentId]: Array.from({ length: count }, (_, index) => ({ id: `history-${index}`, type: "assistant", agent: "build", model,
time: { created: index + 1, completed: index + 2 }, content: [{ type: "text", text: `History ${index}\n\n` + "A deterministic paragraph.\n\n".repeat(2 + index % 5) }] })),
}
const requests: string[] = []
let holdChildReads = false
let failChildRead = false
const heldReads: Array<() => void> = []
const client: any = { session: {
active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
get: async ({ sessionID }: any) => ({ id: sessionID, location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
}, message: { list: async ({ sessionID, limit = 200, cursor }: any) => {
requests.push(sessionID)
if (sessionID === childId && holdChildReads) await new Promise<void>(resolve => heldReads.push(resolve))
if (sessionID === childId && failChildRead) { failChildRead = false; throw new Error("Deterministic read failure") }
const messages = history[sessionID] ?? []
const end = cursor ? Number(cursor) : messages.length, start = Math.max(0, end - limit)
return { data: messages.slice(start, end).reverse(), cursor: start ? { next: String(start) } : {} }
} } }
;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client)
sseManager.getStatuses = () => new Map([[instanceId, "connected"]])
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
setSessions(previous => new Map(previous).set(instanceId, new Map(Object.keys(history).map(id => [id, {
id, instanceId, parentId: id === parentId ? null : parentId, title: id, location: { directory: "/fixture" },
status: "idle", agent: "build", model: { providerId: "fixture", modelId: "fixture" }, time: { created: 1, updated: 1 },
} as any]))))
setActiveSession(instanceId, parentId)
await applyUiSettings({ locale: "en", showMessageTimeline: mode !== "task", toolInputsVisibility: "hidden",
toolCallExpansionDefaults: { preset: "custom", thinking: "collapsed", tools: { task: "expanded", read: "collapsed" } } })
for (const id of Object.keys(history)) await loadMessages(instanceId, id, { force: true })
// Instrument a native operation, not a replacement renderer/store. Only ToolCall
// snapshot inputs are counted; timings below also work with instrumentation off.
let snapshots = 0, snapshotMs = 0
const instrumented = params.get("instrument") !== "off"
const clone = window.structuredClone.bind(window)
if (instrumented) window.structuredClone = ((value: any, options?: StructuredSerializeOptions) => {
const matched = value?.type === "tool" && value.id?.startsWith("step-")
const start = performance.now(), result = clone(value, options)
if (matched) { snapshots++; snapshotMs += performance.now() - start }
return result
}) as typeof structuredClone
const clipboard: string[] = []
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text: string) => { clipboard.push(text) } } })
let eventId = 100
const emit = (type: string, data: any) => (sseManager as any).handleEvent(instanceId, {
id: `event-${++eventId}`, type, created: eventId, location: { directory: "/fixture" },
data: { sessionID: childId, assistantMessageID: "child-message", ...data },
})
const startText = () => {
if (!history[childId][0].content.some((part: any) => part.type === "text")) history[childId][0].content.push({ type: "text", text: "" })
emit("session.text.started", {})
}
const delta = () => {
const text = history[childId][0].content.find((part: any) => part.type === "text")
text.text += " x"
emit("session.text.delta", { ordinal: count, delta: " x" })
}
const store = messageStoreBus.getOrCreate(instanceId)
render(() => <ConfigProvider><I18nProvider><ThemeProvider>
<main style={{ display: "flex", height: "700px", width: "1000px" }}>
<MessageSection instanceId={instanceId} sessionId={parentId} isActive={true} />
</main>
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
let added = 0, removed = 0
const countSteps = (nodes: NodeList) => Array.from(nodes).reduce((total, node) => total + (node instanceof Element
? Number(node.matches('.tool-call[data-part-id^="step-"]')) + node.querySelectorAll('.tool-call[data-part-id^="step-"]').length : 0), 0)
const recordMutations = (records: MutationRecord[]) => {
for (const record of records) { added += countSteps(record.addedNodes); removed += countSteps(record.removedNodes) }
}
const observer = instrumented ? new MutationObserver(recordMutations) : undefined
observer?.observe(document.querySelector("main")!, { childList: true, subtree: true })
;(window as any).fixture = {
startText,
delta,
hold: () => { holdChildReads = true },
release: () => { holdChildReads = false; heldReads.splice(0).forEach(resolve => resolve()) },
reproject: () => loadMessages(instanceId, childId, { force: true }),
replaceChildTools: (indexes: number[], textIndexes: number[] = []) => {
// Same native message identity, but a new authoritative part membership.
history[childId][0].content = indexes.map(index => textIndexes.includes(index)
? { id: `step-${index}`, type: "text", text: output }
: tool(index))
return loadMessages(instanceId, childId, { force: true })
},
failRead: () => { failChildRead = true; delta() },
clearChild: () => { history[childId] = []; return loadMessages(instanceId, childId, { force: true }) },
toolOutput: () => (store.getMessage("child-message")?.parts["step-0"]?.data as any)?.state?.output,
mutateTool: () => batch(() => {
// Deliberately keep data identity and message revision unchanged; only the
// existing normalized part revision invalidates this in-place update.
const set = store.setState as any
set("messages", "child-message", "parts", "step-0", "data", "state", "output", "Versioned in-place output")
set("messages", "child-message", "parts", "step-0", "revision", (revision: number) => revision + 1)
}),
toolUpdate: (index: number, text: string) => {
history[childId][0].content[index].state.content = [{ type: "text", text }]
emit("session.tool.success", { id: `step-${index}`, content: [{ type: "text", text }], metadata: {} })
},
failTool: (index: number) => {
const part = history[childId][0].content.find((part: any) => part.id === `step-${index}`)
part.state = { ...part.state, status: "error", error: { message: "Deterministic failure" } }
emit("session.tool.failed", { id: part.id, error: part.state.error, metadata: {} })
},
removeTool: (index: number) => store.removeMessagePart("child-message", `step-${index}`, childId),
appendTool: () => {
history[childId][0].content.push({ ...tool(count), id: "appended-step", state: {
status: "completed", input: { filePath: "new.txt" }, metadata: {}, content: [{ type: "text", text: "New step output" }],
} })
emit("session.tool.input.started", { id: "appended-step", name: "read" })
emit("session.tool.called", { id: "appended-step", input: { filePath: "new.txt" } })
emit("session.tool.success", { id: "appended-step", content: [{ type: "text", text: "New step output" }], metadata: {} })
},
reset: () => { observer?.takeRecords(); snapshots = 0; snapshotMs = 0; added = 0; removed = 0 },
measure: (updates: number) => {
const start = performance.now()
for (let index = 0; index < updates; index++) delta()
const ms = performance.now() - start
if (observer) recordMutations(observer.takeRecords())
return { ms, snapshots, snapshotMs, added, removed }
},
snapshot: () => ({ snapshots, snapshotMs, added, removed, requests, clipboard, output, held: heldReads.length,
partIds: store.getMessage("child-message")?.partIds,
childRevision: store.getMessage("child-message")?.revision,
loaded: store.getSessionMessageIds(parentId).length }),
}

View file

@ -1,4 +1,4 @@
import { createSignal } from "solid-js"
import { createEffect, createSignal } from "solid-js"
import { render } from "solid-js/web"
import MessagePart from "../../../src/components/message-part"
import MessageTimeline from "../../../src/components/message-timeline"
@ -13,6 +13,14 @@ const code = '<span title="&lt;source&gt;">&amp;</span>'
const body = ["**Markdown still works**", html, "`" + code + "`", "```html\n" + code + "\n```"].join("\n\n")
const malformed = 'Please explain <code><img/src=x onerror="window.htmlExecuted=true"></code> literally\n\ntext <script>if (x<y) z()</script> after'
const [literal, setLiteral] = createSignal(true)
const traceEnabled = new URLSearchParams(location.search).has("trace")
const trace: Array<Record<string, unknown>> = []
const recordCache = (kind: string) => {
const cache = document.querySelector("#cache")
trace.push({ kind, literal: literal(), connected: cache?.isConnected ?? false,
html: cache?.innerHTML, time: performance.now() })
}
if (traceEnabled) createEffect(() => { literal(); queueMicrotask(() => recordCache("literal-effect")) })
const part = { id: "cached-part", type: "text" as const, text: html, version: 1,
renderCache: { text: html, html: '<div class="cached-html">old sanitized HTML</div>', theme: "light", mode: "1:escaped:wrap" } }
@ -28,4 +36,22 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider>
{ id: "assistant-preview", messageId: "assistant-preview", type: "assistant", label: "Assistant preview", tooltip: html, totalChars: html.length },
]} /></div>
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = { html, code, malformed, literal: setLiteral }
if (traceEnabled) {
const cache = document.querySelector("#cache")
if (cache) new MutationObserver(() => recordCache("mutation")).observe(cache, { childList: true, subtree: true })
recordCache("mounted")
}
const tracedSetLiteral = (value: boolean) => {
if (traceEnabled) recordCache("setter-before")
setLiteral(value)
if (traceEnabled) queueMicrotask(() => recordCache("setter-microtask"))
}
;(window as any).fixture = { html, code, malformed,
literal: traceEnabled ? tracedSetLiteral : setLiteral,
snapshot: () => ({ literal: literal(), cache: document.querySelector("#cache")?.innerHTML,
cachedHtml: document.querySelectorAll("#cache .cached-html").length,
trace, renderCache: part.renderCache,
resources: performance.getEntriesByType("resource").map(entry => entry.name)
.filter(name => /(?:solid|markdown|user-html|\.js(?:\?|$))/.test(name)),
}),
}

View file

@ -7,6 +7,33 @@ import solid from "vite-plugin-solid"
import { prepareGitPrototypeAssets } from "./fixtures/git-history-assets.mjs"
let server: ViteDevServer, browser: Browser, url: string
const gitFocusBoundaryInitScript = String.raw`(() => {
const trace = []
window.gitFocusBoundaryTrace = trace
const describeElement = target => target instanceof Element
? { tag: target.tagName, id: target.id, className: target.className, partId: target.getAttribute("data-part-id") }
: null
const pushTrace = entry => {
trace.push(entry)
if (trace.length > 200) trace.shift()
}
for (const type of ["focusin", "focusout", "pointerdown", "click"]) {
document.addEventListener(type, event => {
pushTrace({ kind: type, target: describeElement(event.target), active: describeElement(document.activeElement), time: performance.now() })
}, true)
}
window.addEventListener("DOMContentLoaded", () => {
new MutationObserver(records => {
for (const record of records) for (const node of record.removedNodes) {
if (!(node instanceof Element)) continue
if (!node.matches(".git-change-context-widget, .git-change-context-widget-host")
&& !node.querySelector(".git-change-context-widget")) continue
pushTrace({ kind: "widget-removed", active: describeElement(document.activeElement), time: performance.now() })
}
}).observe(document.documentElement, { childList: true, subtree: true })
}, { once: true })
})()`
test("large Changes inventories keep the reader controls responsive", async () => {
const page = await browser.newPage({ viewport: { width: 2000, height: 1120 } })
try {
@ -592,6 +619,7 @@ test("central diff inserts local lines and revision-qualified history into the r
const errors: string[] = []
page.on("pageerror", error => errors.push(error.stack ?? error.message))
await page.route("**/api/**", route => route.fulfill({ json: {} }))
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) await page.addInitScript({ content: gitFocusBoundaryInitScript })
try {
await page.goto(`${url}?session=1`)
const composer = page.locator(".session-view textarea.prompt-input")
@ -606,6 +634,8 @@ test("central diff inserts local lines and revision-qualified history into the r
await insert.click()
assert.match(await composer.inputValue(), /Conserver ce brouillon/)
assert.match(await composer.inputValue(), /Git Diff: Worktree: \/CodeNomad : File: src\/components\/git-panel.tsx : 3-3/)
const composerElement = (await composer.elementHandle())!
await page.waitForFunction(element => element === document.activeElement, composerElement)
assert.equal(await composer.evaluate(element => element === document.activeElement), true)
// Native editor keyboard selection must keep the full range, not just the hovered line.
@ -659,7 +689,25 @@ test("central diff inserts local lines and revision-qualified history into the r
await page.evaluate(() => (window as any).fixture.switchSession("session"))
assert.equal(await composer.inputValue(), draft)
assert.deepEqual(errors, [])
} finally { await page.close() }
} finally {
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) {
try {
console.error("Git focus boundary trace", await page.evaluate(() => ({
events: (window as any).gitFocusBoundaryTrace,
active: document.activeElement instanceof Element
? { tag: document.activeElement.tagName, id: document.activeElement.id, className: document.activeElement.className }
: null,
widgets: Array.from(document.querySelectorAll<HTMLElement>(".git-change-context-widget")).map(widget => ({
connected: widget.isConnected, rect: widget.getBoundingClientRect().toJSON(),
})),
selections: (window as any).monaco?.editor?.getEditors?.().map((editor: any) => editor.getSelection?.()),
})))
} catch (error) {
console.error("Git focus boundary trace unavailable", error)
}
}
await page.close()
}
})
test("Workspace keyboard focus survives lazy expansion and refresh, including RTL", async () => {

View file

@ -0,0 +1,261 @@
import assert from "node:assert/strict"
import type { IncomingMessage, ServerResponse } from "node:http"
import type { Socket } from "node:net"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import Fastify from "fastify"
import replyFrom from "@fastify/reply-from"
import { OpenCode } from "@opencode/client"
import pino from "pino"
import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import { EventBus } from "../../../server/src/events/bus"
import { ClientConnectionManager } from "../../../server/src/clients/connection-manager"
import { createRuntimeFetch } from "../../../server/src/opencode/compatibility/transport"
import { registerInstanceProxyRoutes, type InstanceProxyWorkspaceManager } from "../../../server/src/server/http-server"
import { registerEventRoutes } from "../../../server/src/server/routes/events"
import { registerMetaRoutes } from "../../../server/src/server/routes/meta"
import { WorktreeDeletionFence } from "../../../server/src/workspaces/worktree-session-evacuation"
let browser: Browser, assets: ViteDevServer, assetUrl: string
before(async () => {
assets = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)),
logLevel: "error", plugins: [solid()], resolve: { dedupe: ["solid-js"] },
server: { host: "127.0.0.1", port: 0, cors: true, hmr: false, watch: null },
})
await assets.listen()
assetUrl = `http://127.0.0.1:${(assets.httpServer!.address() as { port: number }).port}`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => { await browser?.close(); await assets?.close() })
async function until(predicate: () => boolean, description: string) {
// Only a deadlock safety bound, never a responsiveness/performance threshold.
const deadline = Date.now() + 10_000
while (!predicate()) {
if (Date.now() > deadline) throw new Error(`Did not observe ${description}`)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
async function harness() {
// Reuse the production proxy/admission/fetch/fence as in the server disconnect
// diagnostic; use a real upstream SDK instead of copying its fake client tree.
const held = Promise.withResolvers<void>()
let released = false, nativeObservers = 0, nativeHttpA = 0, aborted = 0, heldHandlers = 0
let forwardsA = 0, environmentWrites = 0, invalidations = 0, catalogueRequests = 0
const nativeHttpCatalogues = new Set<ServerResponse>()
const heldMutations = new Set<ServerResponse>()
const captured: Array<{ request: IncomingMessage; response: ServerResponse; abortedListeners: number }> = []
const clients = new Set<() => void>(), sockets = new Set<Socket>()
const backendErrors: string[] = []
const versions = new Set<string>(), bus = new EventBus(), fence = new WorktreeDeletionFence()
const logger = pino({ level: "silent" }), connections = new ClientConnectionManager(logger)
let peakSockets = 0
const upstream = Fastify({ forceCloseConnections: true })
upstream.get("/api/session/:id", async (request, reply) => {
const id = (request.params as { id: string }).id
if (id === "A") {
nativeHttpA++
reply.raw.once("close", () => nativeHttpA--)
heldHandlers++
try { await held.promise } // intentionally ignore disconnect; exercise late completion
finally { heldHandlers-- }
}
return { data: { id, location: { directory: "/fixture" } } }
})
upstream.get("/api/agent/:id", async (_request, reply) => {
catalogueRequests++
nativeHttpCatalogues.add(reply.raw)
reply.raw.once("close", () => nativeHttpCatalogues.delete(reply.raw))
heldHandlers++
try { await held.promise }
finally { heldHandlers-- }
return []
})
upstream.get("/api/location", async () => ({ directory: "/fixture" }))
upstream.post("/api/session/:id/:action", async () => { forwardsA++; return {} })
upstream.put("/api/session/:id/environment", async (_request, reply) => { environmentWrites++; return reply.code(204).send() })
await upstream.listen({ host: "127.0.0.1", port: 0 })
const endpoint = { url: `http://127.0.0.1:${(upstream.server.address() as { port: number }).port}` }
const runtimeFetch = createRuntimeFetch(endpoint)
const native = OpenCode.make({ baseUrl: endpoint.url, fetch: runtimeFetch })
const client = { ...native, session: { ...native.session, get: async (...args: Parameters<typeof native.session.get>) => {
if (args[0].sessionID !== "A") return native.session.get(...args)
nativeObservers++
const signal = args[1]?.signal, onAbort = () => { aborted++ }
signal?.addEventListener("abort", onAbort, { once: true })
try { return await native.session.get(...args) }
finally { nativeObservers--; signal?.removeEventListener("abort", onAbort) }
} } }
const connection = { endpoint, client, fetch: runtimeFetch, assertCurrent() {},
invalidate() { invalidations++ }, profile: async () => "modern" as const }
// Only workspace ownership is supplied here. Reads cross real SDK HTTP; no
// daemon discovery, database, provider, filesystem or shared profile is used.
const manager = {
get: () => ({ id: "workspace", path: "/fixture" }),
getSharedServiceConnection: async () => connection,
getInstanceAuthorizationHeader: () => undefined,
getServiceDirectory: () => "/fixture",
getServiceDirectoryForPath: async () => "/fixture",
getWorktreeIdentityForPath: async () => "/fixture",
getSessionEnvironment: async () => ({}),
ownsDirectory: async (_id: string, directory: string) => directory === "/fixture",
ownsLocation: async (_id: string, location: { directory: string }, _client: unknown, signal?: AbortSignal) =>
location.directory === "/fixture" && (await native.location.get({ location }, { signal })).directory === "/fixture",
} as unknown as InstanceProxyWorkspaceManager
const backend = Fastify({ forceCloseConnections: true })
backend.addHook("onError", async (_request, _reply, error) => { backendErrors.push(error.stack ?? error.message) })
backend.server.on("connection", socket => {
sockets.add(socket); peakSockets = Math.max(peakSockets, sockets.size)
socket.once("close", () => sockets.delete(socket))
})
backend.addHook("onRequest", async (request, reply) => {
versions.add(request.raw.httpVersion)
if (request.method === "POST" && /\/session\/A\/(prompt|compact)$/.test(request.url)) {
captured.push({ request: request.raw, response: reply.raw, abortedListeners: request.raw.listenerCount("aborted") })
heldMutations.add(reply.raw)
reply.raw.once("close", () => { setImmediate(() => heldMutations.delete(reply.raw)) })
}
})
await backend.register(replyFrom)
registerInstanceProxyRoutes(backend, { workspaceManager: manager, logger, worktreeDeletionFence: fence })
registerEventRoutes(backend, { eventBus: bus, logger, connectionManager: connections,
registerClient: close => { clients.add(close); return () => { clients.delete(close) } },
})
backend.get("/api/auth/status", async () => ({ authenticated: true }))
backend.get("/api/witness", async () => ({ witness: true }))
// Business HTTP/SSE goes directly to Fastify, not through Vite's Node proxy.
// Vite serves modules on a separate origin and cannot consume this HTTP budget.
backend.get("/fixture", async (_request, reply) => reply.type("text/html").send(
`<html><body><div id="root"></div><script type="module" src="${assetUrl}/tests/browser/fixtures/http-admission-responsiveness.tsx"></script></body></html>`,
))
const meta = { localUrl: "", eventsUrl: "/api/events", host: "127.0.0.1", listeningMode: "local" as const,
localPort: 0, hostLabel: "private fixture", workspaceRoot: "/fixture", addresses: [] }
registerMetaRoutes(backend, { serverMeta: meta })
await backend.listen({ host: "127.0.0.1", port: 0 })
meta.localPort = (backend.server.address() as { port: number }).port
const url = meta.localUrl = `http://127.0.0.1:${meta.localPort}`
return {
url, captured, fence,
emit() { bus.publish({ type: "instance.eventStatus", instanceId: "workspace", status: "connected", generation: 1 }) },
release() { released = true; held.resolve() },
snapshot() { return { released, nativeObservers, nativeHttpA, aborted, heldHandlers, forwardsA, environmentWrites, invalidations,
catalogueRequests, nativeHttpCatalogues: nativeHttpCatalogues.size, heldMutations: heldMutations.size,
sse: clients.size, sockets: sockets.size, peakSockets, versions: [...versions], backendErrors } },
async dispose() {
held.resolve(); connections.shutdown(); for (const close of clients) close()
await backend.close(); await upstream.close()
},
}
}
async function setup(page: Page, h: Awaited<ReturnType<typeof harness>>) {
const errors: string[] = [], protocols = new Map<string, string>()
page.on("pageerror", error => errors.push(error.message))
const cdp = await page.context().newCDPSession(page)
await cdp.send("Network.enable")
cdp.on("Network.responseReceived", ({ response }) => {
if (response.url.startsWith(h.url)) protocols.set(new URL(response.url).pathname, response.protocol)
})
await page.goto(`${h.url}/fixture`)
await page.getByRole("button", { name: "Switch to B" }).waitFor()
await page.waitForFunction(() => (window as any).fixture.snapshot().opens === 1)
await until(() => h.snapshot().sse === 1, "one real SSE subscription")
const previousEvents = await page.evaluate(() => (window as any).fixture.snapshot().events)
h.emit()
await page.waitForFunction(previous => (window as any).fixture.snapshot().events > previous, previousEvents)
return { errors, protocols }
}
for (const scenario of [
{ action: "prompt", close: "page", background: false },
{ action: "compact", close: "page", background: false },
{ action: "prompt", close: "context", background: false },
{ action: "compact", close: "context", background: false },
{ action: "compact", close: "context", background: true },
] as const) {
test(`HTTP/1 SSE + held ${scenario.action}: ${scenario.close} reconnect${scenario.background ? " with separate backgroundReads(2) occupancy" : ""}`,
{ timeout: 60_000 }, async t => {
const h = await harness(), context = await browser.newContext()
let reconnectContext = context
try {
const page = await context.newPage(), diagnostics = await setup(page, h)
await page.evaluate(action => (window as any).fixture.start(action), scenario.action)
await until(() => h.snapshot().nativeHttpA === 1, "actual SDK session A HTTP preflight")
if (scenario.background) {
await page.evaluate(() => (window as any).fixture.catalogues())
await until(() => h.snapshot().catalogueRequests === 2, "two admitted background catalogue reads")
}
const started = performance.now()
await page.getByRole("textbox", { name: "Draft" }).fill("draft while A is held")
await page.getByRole("button", { name: "Switch to B" }).click()
await page.waitForFunction(() => (window as any).fixture.snapshot().loaded === "B" || (window as any).fixture.snapshot().errors.length > 0)
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).loaded, "B", JSON.stringify({ backend: h.snapshot(), browser: await page.evaluate(() => (window as any).fixture.snapshot()) }))
const info = await page.evaluate(() => (window as any).fixture.info())
const witness = await page.evaluate(() => (window as any).fixture.witness())
assert.deepEqual(info.system, { platform: process.platform, arch: process.arch })
assert.deepEqual(witness, { witness: true })
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.equal(state.draft, "draft while A is held")
assert.equal(state.selected, "B")
assert.equal(state.pending, true)
assert.deepEqual(state.errors, [])
const during = h.snapshot()
assert.equal(during.released, false)
assert.equal(during.nativeObservers, 1)
assert.equal(during.sse, 1)
assert.equal(during.forwardsA, 0)
assert.equal(during.heldHandlers, scenario.background ? 3 : 1)
assert.deepEqual(during.backendErrors, [])
if (scenario.background) {
assert.equal(during.catalogueRequests, 2, "three catalogue intents must remain queued, not occupy HTTP sockets")
assert.equal(during.nativeHttpCatalogues, 2)
assert.equal(state.catalogueSettled, 0)
}
for (const path of ["/api/events", "/api/witness", "/api/meta", "/workspaces/workspace/instance/api/session/B"]) {
assert.equal(diagnostics.protocols.get(path), "http/1.1", `${path} must use real Chromium HTTP/1.1`)
}
t.diagnostic(`browser=${browser.version()} platform=${process.platform}; held-progress=${Math.round(performance.now() - started)} ms; during=${JSON.stringify(during)}`)
assert.deepEqual(diagnostics.errors, [])
const closing = performance.now()
if (scenario.close === "page") await page.close()
else { await context.close(); reconnectContext = await browser.newContext() }
await until(() => {
const state = h.snapshot()
return state.sse === 0 && state.heldMutations === 0 && state.nativeHttpA === 0
&& state.nativeObservers === 0 && state.nativeHttpCatalogues === 0
}, "downstream/SSE/upstream observers retired before releasing the held handler")
assert.equal(h.snapshot().aborted, 1)
for (const entry of h.captured) {
assert.equal(entry.request.listenerCount("aborted"), entry.abortedListeners)
assert.equal(entry.response.listeners("close").some(listener => ["close", "disconnect"].includes(listener.name)), false)
}
await h.fence.run("/fixture", ["/fixture"], async () => {})
assert.equal(h.snapshot().heldHandlers, scenario.background ? 3 : 1, "upstream handlers stay deliberately held after HTTP observers leave")
t.diagnostic(`close-cleanup=${Math.round(performance.now() - closing)} ms; before-release=${JSON.stringify(h.snapshot())}; deletion fence drained`)
const next = await reconnectContext.newPage(), reconnected = await setup(next, h)
await next.getByRole("button", { name: "Switch to B" }).click()
await next.waitForFunction(() => (window as any).fixture.snapshot().loaded === "B")
assert.deepEqual(await next.evaluate(() => (window as any).fixture.witness()), { witness: true })
assert.equal(h.snapshot().released, false, "new page must progress before the old upstream handler is released")
assert.equal(h.snapshot().sse, 1)
const beforeRelease = h.snapshot()
h.release()
await until(() => h.snapshot().heldHandlers === 0, "all deliberately held upstream continuations finished")
await new Promise<void>(resolve => setImmediate(resolve))
await new Promise<void>(resolve => setImmediate(resolve))
assert.equal(h.snapshot().forwardsA, 0, "late preflight completion must not forward/replay the abandoned mutation")
assert.equal(h.snapshot().environmentWrites, 0)
assert.equal(h.snapshot().invalidations, 0)
assert.equal(h.snapshot().catalogueRequests, beforeRelease.catalogueRequests, "destroyed page cannot restart queued catalogue intents")
assert.deepEqual(h.snapshot().versions, ["1.1"])
assert.deepEqual(h.snapshot().backendErrors, [])
assert.deepEqual(reconnected.errors, [])
t.diagnostic(`reconnected-before-release=${JSON.stringify(beforeRelease)}; no late mutation/environment/invalidation`)
} finally { await reconnectContext.close(); await context.close(); await h.dispose() }
})
}

View file

@ -0,0 +1,314 @@
import assert from "node:assert/strict"
import { after, before, test } from "node:test"
import { fileURLToPath } from "node:url"
import { mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
let server: ViteDevServer, browser: Browser, url: string
let cacheDirectory: string | undefined
before(async () => {
// Concurrent browser fixtures must not replace this run's optimized modules.
cacheDirectory = await mkdtemp(join(tmpdir(), "codenomad-render-vite-"))
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", cacheDir: cacheDirectory,
plugins: [solid(), { name: "render-cost-fixture", configureServer(s) {
s.middlewares.use("/fixture", async (req, res) => {
res.setHeader("Content-Type", "text/html")
const name = req.url?.includes("mode=timeline") ? "render-cost-timeline" : "render-cost"
res.end(await s.transformIndexHtml("/fixture", `<html><body><div id="root"></div><script type="module" src="/tests/browser/fixtures/${name}.tsx"></script></body></html>`))
})
}, async load(id) {
// Optional audit-only A/B source override; never changes the checkout.
if (process.env.CODENOMAD_RENDER_TASK_BASELINE && id.replaceAll("\\", "/").endsWith("/src/components/tool-call/renderers/task.tsx")) {
return readFile(process.env.CODENOMAD_RENDER_TASK_BASELINE, "utf8")
}
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
})
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
})
after(async () => {
await browser?.close()
await server?.close()
if (cacheDirectory) await rm(cacheDirectory, { recursive: true, force: true })
})
async function open(query: string, run: (page: Page) => Promise<void>) {
const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" })
const errors: string[] = []
page.on("pageerror", error => { errors.push(error.message); console.error("fixture-error", error.message) })
await page.route("**/*", route => {
const request = new URL(route.request().url())
if (request.origin !== new URL(url).origin) return route.abort("blockedbyclient")
if (request.pathname.startsWith("/api/")) return route.fulfill({ contentType: "application/json", body: "{}" })
return route.continue()
})
try {
await page.goto(`${url}?${query}`)
await page.waitForFunction(() => Boolean((window as any).fixture))
await run(page)
assert.deepEqual(errors, [])
} finally { await page.close() }
}
const step = (page: Page, index = 0) => page.locator(`.tool-call[data-part-id="step-${index}"]`)
const frames = (page: Page) => page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
test("unrelated child text keeps mounted task steps, disclosure, scroller and copies", async () => open("mode=task&count=12", async page => {
const tool = step(page)
await tool.waitFor()
await page.evaluate(() => (window as any).fixture.startText())
await frames(page)
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
await tool.locator("pre code").waitFor()
await frames(page)
await tool.evaluate(element => { (window as any).originalStep = element })
await page.evaluate(() => (window as any).fixture.reproject())
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "authoritative reprojection must not recreate the tool shell")
const output = tool.locator(".tool-call-markdown")
const box = (await output.boundingBox())!
await page.mouse.move(box.x + 30, box.y + 60)
await page.mouse.down({ button: "middle" })
try {
await output.evaluate(element => { (window as any).originalScroller = element; element.scrollTop = 240 })
await page.evaluate(() => { (window as any).fixture.hold(); (window as any).fixture.reset(); (window as any).fixture.delta() })
await page.waitForFunction(() => (window as any).fixture.snapshot().held > 0)
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "pending display refresh must retain mounted steps")
await page.evaluate(() => (window as any).fixture.release())
await frames(page)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
console.log("unrelated-update", { snapshots: state.snapshots, childRevision: state.childRevision, added: state.added, removed: state.removed })
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true, "unrelated text must not remount a completed tool")
assert.equal(await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
assert.equal(await output.evaluate(element => element === (window as any).originalScroller), true)
assert.equal(await output.evaluate(element => element.scrollTop), 240)
assert.equal(state.added + state.removed, 0)
} finally { await page.mouse.up({ button: "middle" }) }
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.equal(state.clipboard[0], state.output)
await page.evaluate(() => (window as any).fixture.toolUpdate(0, "Changed native output"))
await page.waitForFunction(() => document.querySelector('.tool-call[data-part-id="step-0"]')?.textContent?.includes("Changed native output"))
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).clipboard.at(-1), "Changed native output")
await page.evaluate(() => (window as any).fixture.mutateTool())
await page.waitForFunction(() => document.querySelector('.tool-call[data-part-id="step-0"]')?.textContent?.includes("Versioned in-place output"))
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
await page.evaluate(() => (window as any).fixture.failTool(0))
await tool.locator(".tool-call-error-content").waitFor()
assert.match((await tool.textContent())!, /Deterministic failure/)
await page.evaluate(() => (window as any).fixture.removeTool(0))
await tool.waitFor({ state: "detached" })
await page.evaluate(() => (window as any).fixture.clearChild())
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 0, "authoritative empty pages must clear stale displayed steps")
}))
test("a failed child refresh keeps the last display snapshot with an explicit retry error", async () => open("mode=task&count=12", async page => {
const tool = step(page)
await tool.waitFor()
await page.evaluate(() => (window as any).fixture.startText())
await frames(page)
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
await tool.locator("pre code").waitFor()
await tool.evaluate(element => { (window as any).originalStep = element })
await page.evaluate(() => (window as any).fixture.failRead())
await page.getByRole("alert").filter({ hasText: "Deterministic read failure" }).waitFor()
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
assert.equal(await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
await page.getByRole("alert").getByRole("button").click()
await page.getByRole("alert").waitFor({ state: "detached" })
assert.equal(await tool.evaluate(element => element === (window as any).originalStep), true)
}))
test("native page output must not reuse zero-revision Markdown when a tool is reopened", async () => open("mode=task&count=1", async page => {
const tool = step(page), toggle = tool.locator(":scope > .tool-call-header > .tool-call-header-toggle")
await tool.waitFor()
await toggle.click()
await tool.locator("pre code").waitFor()
await page.evaluate(() => (window as any).fixture.toolUpdate(0, "Replacement native page output"))
await page.waitForFunction(() => (window as any).fixture.toolOutput() === "Replacement native page output")
await frames(page)
// The old renderer closes on reprojection. Reopen it to distinguish a stale
// render-cache hit from the separately asserted disclosure/remount defect.
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click()
await tool.locator("pre code").waitFor()
await frames(page)
assert.equal((await tool.locator("pre code").textContent())?.trim() === "Replacement native page output", true,
"the native record changed but the displayed Markdown is stale")
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).clipboard.at(-1), "Replacement native page output")
}))
test("changing an indexed task key does not transfer a removed tool's disclosure", async () => open("mode=task&count=200", async page => {
const first = step(page)
await first.waitFor()
await first.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
await first.locator("pre code").waitFor()
await page.evaluate(() => (window as any).fixture.appendTool())
await first.waitFor({ state: "detached" })
assert.equal(await step(page, 1).locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "false")
assert.equal(await page.locator('.tool-call[data-part-id="appended-step"]').count(), 1)
}))
for (const retainedIndex of [0, 1]) {
test(`authoritative task membership 2->1->2 preserves retained step-${retainedIndex}`, async () => open("mode=task&count=2", async page => {
const retained = step(page, retainedIndex)
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
await retained.waitFor()
assert.equal(await counter.textContent(), "2 steps")
await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
const output = retained.locator(".tool-call-markdown")
await retained.locator("pre code").waitFor()
await frames(page)
await retained.evaluate(element => { (window as any).retainedShell = element })
await output.evaluate(element => { (window as any).retainedScroller = element; element.scrollTop = 240 })
await page.evaluate(index => (window as any).fixture.replaceChildTools([index]), retainedIndex)
await frames(page)
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 1)
assert.equal(await counter.textContent(), "1 steps", "removed parts must also leave the membership counter")
await page.evaluate(index => (window as any).fixture.replaceChildTools([index, 2]), retainedIndex)
await frames(page)
assert.equal(await counter.textContent(), "2 steps")
assert.equal(await step(page, 2).count(), 1, "new identity at the former cardinality must appear exactly once")
assert.equal(await retained.evaluate(element => element === (window as any).retainedShell), true)
assert.equal(await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
assert.equal(await output.evaluate(element => element === (window as any).retainedScroller), true)
assert.equal(await output.evaluate(element => element.scrollTop), 240)
}))
}
test("authoritative task membership replaces and reorders equal-cardinality identities", async () => open("mode=task&count=2", async page => {
const retained = step(page, 0)
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
await retained.waitFor()
await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
await retained.locator("pre code").waitFor()
await retained.evaluate(element => { (window as any).retainedShell = element })
await page.evaluate(() => (window as any).fixture.replaceChildTools([2, 0]))
await frames(page)
assert.equal(await counter.textContent(), "2 steps")
assert.deepEqual(await page.locator('.tool-call[data-part-id^="step-"]').evaluateAll(elements => elements.map(element => element.getAttribute("data-part-id"))), ["step-2", "step-0"])
assert.equal(await step(page, 1).count(), 0)
assert.equal(await retained.evaluate(element => element === (window as any).retainedShell), true)
assert.equal(await retained.locator(":scope > .tool-call-header > .tool-call-header-toggle").getAttribute("aria-expanded"), "true")
}))
test("authoritative task membership drops a tool changed to text under the same part id", async () => open("mode=task&count=2", async page => {
await step(page, 1).waitFor()
await page.evaluate(() => (window as any).fixture.replaceChildTools([0, 1], [1]))
await frames(page)
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+ steps$/ })
assert.equal(await counter.textContent(), "1 steps")
assert.equal(await step(page, 1).count(), 0)
assert.equal(await step(page, 0).count(), 1)
}))
test("authoritative task membership keeps the 200-step bound and clears stale truncation after shrink", async () => open("mode=task&count=201", async page => {
await step(page, 200).waitFor()
const counter = page.locator('.tool-call[data-part-id="parent-task"] .tool-call-task-section-meta').filter({ hasText: /^\d+\+? steps$/ })
assert.equal(await counter.textContent(), "200+ steps")
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 200)
await page.evaluate(() => (window as any).fixture.replaceChildTools(Array.from({ length: 199 }, (_, index) => index + 2)))
await frames(page)
assert.equal(await counter.textContent(), "199 steps")
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 199)
assert.equal(await page.locator('.tool-call[data-part-id="parent-task"] .tool-call-diagnostic-message').count(), 0)
}))
test("audit sample: deterministic native child deltas (timings are observations, not a CI threshold)", async () => open(`mode=task&count=80&instrument=${process.env.CODENOMAD_RENDER_INSTRUMENT ?? "on"}`, async page => {
await step(page, 79).waitFor()
await page.evaluate(() => (window as any).fixture.startText())
await frames(page)
await page.evaluate(() => (window as any).fixture.measure(3))
await frames(page)
const samples = []
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
const metrics = async () => {
const { metrics } = await cdp.send("Performance.getMetrics")
const ms = (name: string) => metrics.find(metric => metric.name === name)!.value * 1000
return { scriptMs: ms("ScriptDuration"), taskMs: ms("TaskDuration"), layoutMs: ms("LayoutDuration"), styleMs: ms("RecalcStyleDuration") }
}
for (let index = 0; index < 10; index++) {
const before = await metrics()
const sync = await page.evaluate(() => { const f = (window as any).fixture; f.reset(); return f.measure(1) })
await frames(page)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
const after = await metrics()
samples.push({ syncMs: sync.ms, scriptMs: after.scriptMs - before.scriptMs, taskMs: after.taskMs - before.taskMs,
layoutMs: after.layoutMs - before.layoutMs, styleMs: after.styleMs - before.styleMs,
snapshots: state.snapshots, added: state.added, removed: state.removed })
}
console.log("render-cost-native-deltas", JSON.stringify({ variant: process.env.CODENOMAD_RENDER_TASK_BASELINE ? "baseline" : "fixed",
browser: browser.version(), instrumented: process.env.CODENOMAD_RENDER_INSTRUMENT !== "off", samples }))
if (!process.env.CODENOMAD_RENDER_TASK_BASELINE && process.env.CODENOMAD_RENDER_INSTRUMENT !== "off") {
assert.ok(samples.every(sample => sample.added === 0 && sample.removed === 0), "structural rescans must not remount unchanged task shells")
}
assert.equal(await page.locator('.tool-call[data-part-id^="step-"]').count(), 80)
}))
test("large tool output bounds rendered text but preserves full clipboard source", async () => open("mode=task&count=1&size=2000000", async page => {
const tool = step(page)
await tool.waitFor()
assert.equal(await tool.locator(".tool-call-details").count(), 0, "collapsed steps must not mount their bodies")
const start = await page.evaluate(() => performance.now())
await tool.locator(":scope > .tool-call-header > .tool-call-header-toggle").click()
await tool.locator("pre code").waitFor()
const content = await tool.locator("pre code").textContent()
assert.ok(content && content.length <= 10_000)
assert.ok((await tool.textContent())!.includes("Output truncated"))
console.log("large-output", { renderedCharacters: content!.length, ...await page.evaluate(start => ({ msToObservedRender: performance.now() - start, nodes: document.querySelectorAll("*").length }), start) })
await tool.locator(":scope > .tool-call-header > .tool-call-header-copy").first().click()
const state = await page.evaluate(() => (window as any).fixture.snapshot())
assert.equal(state.clipboard[0], state.output)
assert.equal(state.output.length, 2_000_000)
}))
test("transcript loads one bounded history page and mounts only virtual rows", async () => open("mode=transcript&count=1000", async page => {
await page.waitForFunction(() => document.querySelector(".message-stream")?.textContent?.includes("History 999"))
await frames(page)
const state = await page.evaluate(() => (window as any).fixture.snapshot())
const rows = await page.locator("[data-virtual-follow-key]").count()
const markers = await page.locator(".timeline-virtual-row").count()
console.log("transcript-bounds", { loaded: state.loaded, rows, markers, requests: state.requests })
assert.equal(state.loaded, 200)
assert.ok(rows < 40)
assert.ok(markers < 80)
}))
test("timeline exact extent, bounded DOM and active highlight updates do not mount messages", async () => open("mode=timeline&count=10000", async page => {
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().waitFor()
await frames(page)
const samples = []
for (let index = 0; index < 10; index++) {
samples.push(await page.evaluate(() => (window as any).fixture.measure(30)))
await frames(page)
}
console.log("timeline-active-updates", JSON.stringify({ samples, snapshot: await page.evaluate(() => (window as any).fixture.snapshot()) }))
const geometry = await page.evaluate(() => {
const content = document.querySelector<HTMLElement>(".timeline-virtual-content")!
const marker = document.querySelector<HTMLElement>(".timeline-measure button")!
const gap = document.querySelector<HTMLElement>(".timeline-measure div")!
const markerHeight = parseFloat(getComputedStyle(marker).height), gapHeight = parseFloat(getComputedStyle(gap).height)
// Each 3-marker group has a double gap before its tool and following user.
let units = 0
for (let index = 1; index < 10000; index++) units += index % 3 === 2 ? 1 : 2
return { extent: parseFloat(content.style.height), expected: markerHeight * 10000 + gapHeight * units, mounted: document.querySelectorAll(".timeline-virtual-row").length }
})
console.log("timeline-geometry", geometry)
// CSS serialization and native scroll extents round at this magnitude.
assert.ok(Math.abs(geometry.extent - geometry.expected) < 1)
assert.ok(geometry.mounted < 80)
assert.equal(await page.locator(".tool-call, [data-view=message-item]").count(), 0)
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().hover()
await page.getByRole("tooltip").locator("strong").waitFor()
assert.ok((await page.getByRole("tooltip").textContent())!.length <= 4096)
await page.locator('.message-timeline-segment[data-message-id="message-0"]').first().click()
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).selected, "marker-0")
}))

View file

@ -34,7 +34,15 @@ after(async () => { await browser?.close(); await server?.close() })
async function open(name: string, run: (page: Page) => Promise<void>) {
const page = await browser.newPage({ viewport: { width: 1100, height: 700 }, locale: "en-US" })
const errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
const boundaryDiagnostics: Array<Record<string, unknown>> = []
page.on("pageerror", error => {
errors.push(error.message)
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) boundaryDiagnostics.push({ kind: "pageerror", message: error.message, stack: error.stack })
})
if (process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE) {
page.on("console", message => boundaryDiagnostics.push({ kind: "console", type: message.type(), text: message.text() }))
page.on("requestfailed", request => boundaryDiagnostics.push({ kind: "requestfailed", url: request.url(), error: request.failure()?.errorText }))
}
await page.addInitScript(`(() => {
window.fixtureScrollEvents = [];
for (const type of ['wheel', 'scroll', 'pointerdown']) document.addEventListener(type, event => {
@ -55,7 +63,7 @@ async function open(name: string, run: (page: Page) => Promise<void>) {
console.error("Browser fixture failure", name, await page.evaluate(() => ({
state: (window as any).fixture?.snapshot?.(), events: (window as any).fixtureScrollEvents,
streams: Array.from(document.querySelectorAll(".message-stream")).map(el => ({ top: el.scrollTop, height: el.scrollHeight, viewport: el.clientHeight })),
})))
})), { boundaryDiagnostics })
throw error
} finally { await page.close() }
}
@ -80,7 +88,7 @@ test("user HTML remains literal through live delivery and history while assistan
})
test("user HTML mode preserves Markdown, pasted disclosures and code source and fences legacy caches", async () => {
await open("user-html", async page => {
await open(process.env.CODENOMAD_BROWSER_BOUNDARY_TRACE ? "user-html&trace=1" : "user-html", async page => {
await page.locator("#root").evaluate(root => { root.style.display = "block"; root.style.height = "auto" })
await page.locator("#user strong").waitFor()
await page.locator("#assistant .sample-html").waitFor()

View file

@ -0,0 +1,57 @@
import { DatabaseSync } from "node:sqlite"
import { performance } from "node:perf_hooks"
import { setImmediate as turn } from "node:timers/promises"
import { execFileSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import { buildSync } from "esbuild"
import { readSessionOutline as currentOutline } from "../packages/server/src/opencode/session-pruning/outline-index"
const baseline = process.argv.includes("--baseline")
let readSessionOutline = currentOutline
if (baseline) {
// Compile the immutable pre-mission source without changing the working tree.
const directory = fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/", import.meta.url))
const source = execFileSync("git", ["show", "366830f6:packages/server/src/opencode/session-pruning/outline-index.ts"],
{ cwd: directory, encoding: "utf8" })
const built = buildSync({ stdin: { contents: source, loader: "ts", resolveDir: directory }, bundle: true,
platform: "node", format: "esm", write: false })
readSessionOutline = (await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString("base64")}`)).readSessionOutline
}
// Synthetic memory-only database. Never discovers or opens an OpenCode database.
const count = Number(process.argv[2] ?? 256)
const bytes = Number(process.argv[3] ?? 262144)
if (!Number.isInteger(count) || count < 1 || count > 1024 || !Number.isInteger(bytes) || bytes < 1 || bytes > 1048576) {
throw new Error("Use 1–1024 rows and 1–1048576 payload bytes per row")
}
const db = new DatabaseSync(":memory:")
try {
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT,time_updated INTEGER DEFAULT 0);
CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message(session_id,seq);
INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`)
const data = JSON.stringify({ content: [{ type: "tool", name: "read", state: { content: [{ type: "text", text: "x".repeat(bytes) }] } }] })
const insert = db.prepare("INSERT INTO session_message(id,session_id,type,seq,data) VALUES (?,'s','assistant',?,?)")
for (let i = 0; i < count; i++) insert.run(`m${i}`, i, data)
for (let sample = 0; sample < 3; sample++) {
await turn()
const gaps: number[] = []
let previous = performance.now()
const start = previous
let stopped = false
const tick = () => {
if (stopped) return
const now = performance.now()
gaps.push(now - previous)
previous = now
setImmediate(tick)
}
setImmediate(tick)
const result = await readSessionOutline(db, { directory: "/repo", projectID: "p", sessionID: "s" }, undefined, new AbortController().signal)
const elapsed = performance.now() - start
await turn()
stopped = true
console.log(JSON.stringify({ baseline, sample, count, bytes, entries: result.status === "outline" ? result.entries.length : -1,
elapsedMs: Math.round(elapsed), maxTurnGapMs: Math.round(Math.max(...gaps)), turns: gaps.length }))
}
} finally { db.close() }

View file

@ -0,0 +1,383 @@
// Explicit CLI only: no discovery, shared service, user database or external provider.
import assert from "node:assert/strict"
import { execFileSync, spawn } from "node:child_process"
import { createHash } from "node:crypto"
import { createServer } from "node:http"
import { mkdtemp, mkdir, realpath, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { fileURLToPath } from "node:url"
import { OpenCode } from "@opencode/client"
import { build } from "esbuild"
const cli = process.argv[2]
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute isolated CLI executable")
const contextFlag = process.argv.indexOf("--context-mib")
const contextMiB = contextFlag < 0 ? 0 : Number(process.argv[contextFlag + 1])
if (![0, 2, 8, 32].includes(contextMiB)) throw new Error("--context-mib accepts only 0, 2, 8 or 32")
const skipOutline = process.argv.includes("--skip-outline")
const temporaryRoot = path.join(os.tmpdir(), "opencode")
await mkdir(temporaryRoot, { recursive: true })
const root = await realpath(await mkdtemp(path.join(temporaryRoot, "compaction-isolation-")))
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/i.test(key)))
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
Object.assign(env, {
HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "test.db"),
OPENCODE_SERVER_PASSWORD: "private-compaction-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
})
const runtimeVersion = execFileSync(cli, ["--version"], { cwd: root, env, encoding: "utf8" }).trim().replace(/^opencode2? v/, "")
assert.match(runtimeVersion, /^2\.0\.(19|21)$/, "This fixture qualifies only the requested runtimes")
await Promise.all(["config", "plugin", "pruning"].map(name => mkdir(path.join(root, name), { recursive: true })))
const bundle = await build({
entryPoints: [fileURLToPath(new URL("../packages/server/src/opencode/session-pruning/plugin.ts", import.meta.url))],
bundle: true, platform: "node", format: "esm", target: "node22", write: false,
banner: { js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);' },
})
await writeFile(path.join(root, "pruning", "index.mjs"), bundle.outputFiles[0].contents)
await writeFile(path.join(root, "plugin", "index.ts"), `
export default { id:'private-compaction-fixture', async setup(ctx) {
await ctx.session.hook('http.request', event => {
event.request.headers.set('x-fixture-kind', event.kind)
event.request.headers.set('x-fixture-session', event.sessionID)
})
await ctx.tool.transform(editor => editor.add({ name:'isolation_probe', description:'Inert fixture',
input:{type:'object',properties:{}}, options:{codemode:false}, execute:async()=>({content:'Synthetic tool result'}) }))
} }
`)
const gates = new Map(), requests = [], toolIssued = new Set()
const gate = key => {
let release
const promise = new Promise(resolve => { release = resolve })
const value = { promise, release, released: false }
gates.set(key, value)
return () => { value.released = true; release(); gates.delete(key) }
}
const summary = ["## Objective", "## Requirements", "## Decisions", "## Work State", "### Completed",
"### Active", "### Blocked", "## Next Move", "## Relevant Files", "## Important Context"]
.map(heading => `${heading}\n- Synthetic isolation fixture`).join("\n\n")
let providerError
const provider = createServer(async (request, response) => {
try {
const started = performance.now()
let raw = ""
for await (const chunk of request) raw += chunk
const bodyReceived = performance.now()
const body = JSON.parse(raw), kind = request.headers["x-fixture-kind"], sessionID = request.headers["x-fixture-session"]
const record = { kind, sessionID, started, bodyReceived, received: performance.now(), bytes: Buffer.byteLength(raw),
messageCount: body.messages?.length, markerCount: (raw.match(/NATIVE_CONTEXT_CANARY_\d+/g) ?? []).length,
payloadFillBytes: (raw.match(/Z{1024,}/g) ?? []).reduce((sum, fill) => sum + fill.length, 0) }
requests.push(record)
const pending = gates.get(`${kind}:${sessionID}`)
if (pending) await pending.promise
if (response.destroyed) return
const text = kind === "compaction" ? summary : "Synthetic conclusion"
if (!body.stream) {
response.setHeader("Content-Type", "application/json")
response.end(JSON.stringify({ id: "aux", choices: [{ message: { role: "assistant", content: text }, finish_reason: "stop" }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }))
return
}
response.setHeader("Content-Type", "text/event-stream")
const chunk = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({
id: "fixture", object: "chat.completion.chunk", model: "fixture",
choices: [{ index: 0, delta, finish_reason }],
...(finish_reason ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } : {}),
})}\n\n`)
chunk({ role: "assistant" })
const tool = kind === "primary" && !toolIssued.has(sessionID)
if (tool) {
toolIssued.add(sessionID)
chunk({ tool_calls: [{ index: 0, id: `inert-${sessionID}`, type: "function", function: { name: "isolation_probe", arguments: "{}" } }] })
} else {
// Exercise real compaction delta events, without an artificial slow observer.
for (let i = 0; i < text.length; i += 16) chunk({ content: text.slice(i, i + 16) })
}
chunk({}, tool ? "tool_calls" : "stop")
response.end("data: [DONE]\n\n")
record.responded = performance.now()
} catch (error) { providerError = error; response.destroy(error) }
})
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
model: "fixture/fixture",
// Fixture-only: preserve the entire imported corpus in a manual summary request.
...(contextMiB ? { compaction: { auto: false, keep: { tokens: 0 } } } : {}),
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible", settings: {
baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only",
}, models: { fixture: contextMiB ? { limit: { context: 16777216, output: 8192 } } : {} } } },
plugins: [path.join(root, "plugin"), { package: path.join(root, "pruning"), options: { databasePath: env.OPENCODE_DB } }],
})
let output = "", observerError, spawnError
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--log-level", "debug", "--print-logs"], { cwd: root, env, windowsHide: true })
const stopped = new Promise(resolve => child.once("close", resolve))
child.on("error", error => { spawnError = error })
child.stdout.on("data", data => { output += data })
child.stderr.on("data", data => { output += data })
const streams = new AbortController(), events = [], measurements = []
let phase = "setup"
const report = { runtimeVersion, root, pid: child.pid, contextMiB, skipOutline, measurements, requests, outline: undefined,
pruningBundleSha256: createHash("sha256").update(bundle.outputFiles[0].contents).digest("hex") }
let observer, client, releaseA, releaseB
const options = () => ({ signal: AbortSignal.timeout(15_000) })
async function until(predicate, timeout = 30_000) {
const end = performance.now() + timeout
while (performance.now() < end) {
if (providerError) throw providerError
if (observerError) throw observerError
if (spawnError) throw spawnError
if (child.exitCode !== null) throw new Error("Private server exited early")
if (await predicate()) return
await delay(20)
}
throw new Error("Private fixture condition timed out")
}
async function measured(name, call) {
const started = performance.now()
const startedPhase = phase
try { const value = await call(); const finished = performance.now(); measurements.push({ name, started, finished, startedPhase, finishedPhase: phase, ms: finished - started, status: "ok" }); return value }
catch (error) { const finished = performance.now(); measurements.push({ name, started, finished, startedPhase, finishedPhase: phase, ms: finished - started, status: "failed" }); throw error }
}
const wait = sessionID => client.session.wait({ sessionID }, options())
function distribution(values) {
const sorted = [...values].sort((a, b) => a - b)
return { count: sorted.length, min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], max: sorted.at(-1) }
}
try {
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
report.baseUrl = baseUrl
client = OpenCode.make({ baseUrl, headers: { Authorization: `Basic ${Buffer.from("opencode:private-compaction-fixture").toString("base64")}` } })
const info = await client.server.info(options())
assert.equal(info.version, runtimeVersion)
assert.equal(info.pid, child.pid, "The endpoint must belong to the child we created")
observer = (async () => {
for await (const event of client.event.subscribe({ signal: streams.signal })) {
events.push({ phase, type: event.type, sessionID: event.data?.sessionID, created: event.created, received: Date.now() })
}
})().catch(error => { if (!streams.signal.aborted) observerError = error })
await until(() => events.some(event => event.type === "server.connected"))
const location = { directory: root }
let a = await client.session.create({ location, title: "Private compaction A" }, options())
const b = await client.session.create({ location, title: "Private witness B" }, options())
report.sessions = { a: a.id, b: b.id }
await until(async () => {
const plugins = (await client.plugin.list({ location }, options())).data
const failed = plugins.filter(item => item.source.type !== "builtin" && item.state.status === "failed")
assert.equal(failed.length, 0, JSON.stringify(failed))
return plugins.some(item => item.id === "codenomad-session-pruning" && item.state.status === "active")
})
phase = "seed"
for (const session of [a, b]) {
await client.session.prompt({ sessionID: session.id, text: "Use isolation_probe, then conclude." }, options())
await wait(session.id)
}
const nativeMessages = (await client.message.list({ sessionID: a.id, limit: 20 }, options())).data
const template = nativeMessages.find(message => message.type === "assistant" && message.content.some(part => part.type === "tool"))
assert(template, "A real native assistant/tool template must exist")
const userTemplate = nativeMessages.find(message => message.type === "user")
assert(userTemplate)
const tool = template.content.find(part => part.type === "tool")
assert.equal(tool.state.status, "completed")
if (contextMiB) {
const exportedA = await client.session.export({ sessionID: a.id }, options())
const count = contextMiB * 4, bytes = 262144
let fillBytes = 0
const imported = Array.from({ length: count }, (_, index) => {
const marker = `NATIVE_CONTEXT_CANARY_${String(index).padStart(4, "0")}\n`
const text = marker + "Z".repeat(bytes - marker.length)
fillBytes += bytes - marker.length
return [
{ ...structuredClone(userTemplate), id: `msg_context_user_${String(index).padStart(4, "0")}`, text: `Inspect synthetic item ${index}.` },
{ ...structuredClone(template), id: `msg_context_assistant_${String(index).padStart(4, "0")}`,
content: [{ ...structuredClone(tool), id: `tool_context_${index}`, state: { ...structuredClone(tool.state), content: [{ type: "text", text }] } }] },
]
}).flat()
// Native manual compaction retains the current turn even with keep.tokens=0.
// A small trailing turn puts every large tool result in the summarized prefix.
imported.push(
{ ...structuredClone(userTemplate), id: "msg_context_tail_user", text: "Corpus complete; retain this small trailing turn." },
{ ...structuredClone(template), id: "msg_context_tail_assistant", content: [{ type: "text", text: "Synthetic trailing conclusion" }] },
)
await client.session.remove({ sessionID: a.id }, options())
a = await client.session.import({ ...exportedA, messages: imported, location }, { signal: AbortSignal.timeout(60_000) })
report.sessions.a = a.id
const stored = await client.session.context({ sessionID: a.id }, { signal: AbortSignal.timeout(60_000) })
const storedTools = stored.filter(message => message.type === "assistant").flatMap(message => message.content.filter(part => part.type === "tool"))
const storedBytes = storedTools.reduce((sum, part) => sum + part.state.content.reduce((total, content) => total + Buffer.byteLength(content.text ?? ""), 0), 0)
assert.equal(storedTools.length, count)
assert.equal(storedBytes, contextMiB * 1048576)
assert.equal(stored.length, imported.length)
report.contextCorpus = { turns: count + 1, payloadTurns: count, messages: imported.length, payloadBytes: storedBytes, payloadFillBytes: fillBytes,
keepTokens: 0, modelContextLimit: 16777216 }
}
// Seeding is outside the timed compaction/outline intervals and uses native import.
let corpus
if (!skipOutline) {
const empty = await client.session.create({ location, title: "Private outline corpus" }, options())
const exported = await client.session.export({ sessionID: empty.id }, options())
await client.session.remove({ sessionID: empty.id }, options())
const payload = "x".repeat(262144)
const messages = Array.from({ length: 128 }, (_, index) => ({ ...structuredClone(template), id: `msg_isolation_${index}`,
content: [{ ...structuredClone(tool), id: `tool_isolation_${index}`, state: { ...structuredClone(tool.state), content: [{ type: "text", text: payload }] } }],
}))
corpus = await client.session.import({ ...exported, messages, location }, { signal: AbortSignal.timeout(60_000) })
}
// Flush seed observations before the measured phase; import is not compaction.
await delay(100)
phase = "assembly-before-provider"
const compactStart = performance.now()
releaseA = gate(`compaction:${a.id}`)
const admission = measured("A.compact.admission", () => client.session.compact({ sessionID: a.id }, options()))
admission.catch(() => {})
let probingAssembly = true
const assemblyProbes = (async () => {
while (probingAssembly && !requests.some(item => item.kind === "compaction" && item.sessionID === a.id)) {
await Promise.all([
measured("assembly.info", () => client.server.info(options())),
measured("assembly.B.get", () => client.session.get({ sessionID: b.id }, options())),
])
if (probingAssembly) await delay(5)
}
})()
assemblyProbes.catch(() => {})
const earlyB = (async () => {
await measured("assembly.B.prompt.admission", () => client.session.prompt({ sessionID: b.id, text: "Conclude during A summary assembly." }, options()))
await measured("assembly.B.wait", () => wait(b.id))
assert.equal((await client.session.get({ sessionID: b.id }, options())).outcome, "succeeded")
})()
earlyB.catch(() => {})
const admitted = await admission
assert.equal(typeof admitted.time.created, "number")
try { await until(() => requests.some(item => item.kind === "compaction" && item.sessionID === a.id)) }
finally { probingAssembly = false; await assemblyProbes }
const assembled = requests.find(item => item.kind === "compaction" && item.sessionID === a.id)
const admittedAt = measurements.find(item => item.name === "A.compact.admission").finished
report.compactionAssembly = { submissionToProviderHeadersMs: assembled.started - compactStart,
admissionResponseToProviderHeadersMs: assembled.started - admittedAt,
submissionToProviderBodyMs: assembled.bodyReceived - compactStart, providerParseMs: assembled.received - assembled.bodyReceived,
providerBytes: assembled.bytes, providerMessages: assembled.messageCount, markerCount: assembled.markerCount, payloadFillBytes: assembled.payloadFillBytes }
if (contextMiB) {
assert.equal(assembled.markerCount, report.contextCorpus.payloadTurns, "Provider must receive every corpus tool marker")
assert.equal(assembled.payloadFillBytes, report.contextCorpus.payloadFillBytes, "Stored corpus bytes must really reach the compaction provider")
}
await until(() => events.some(event => event.sessionID === a.id && event.type === "session.compaction.started"))
await earlyB
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
report.earlyBCompletedBeforeReleaseA = true
report.assemblyWitnesses = measurements.filter(item => item.name.startsWith("assembly.")).map(item => ({ ...item,
startedBeforeProviderHeaders: item.started < assembled.started, completedBeforeProviderHeaders: item.finished < assembled.started,
completedBeforeProviderBody: item.finished < assembled.bodyReceived }))
for (const name of ["assembly.info", "assembly.B.get", "assembly.B.prompt.admission"]) {
assert(report.assemblyWitnesses.some(item => item.name === name && item.startedBeforeProviderHeaders), `${name} must be issued before A reaches the provider`)
}
report.preProviderLatencyMs = Object.fromEntries([...new Set(report.assemblyWitnesses.map(item => item.name))]
.map(name => [name, distribution(report.assemblyWitnesses.filter(item => item.name === name && item.startedBeforeProviderHeaders).map(item => item.ms))]))
report.whollyPreProviderLatencyMs = Object.fromEntries([...new Set(report.assemblyWitnesses.map(item => item.name))]
.map(name => [name, distribution(report.assemblyWitnesses.filter(item => item.name === name && item.completedBeforeProviderHeaders).map(item => item.ms))]))
report.bCompletedBeforeProviderHeaders = report.assemblyWitnesses.some(item => item.name === "assembly.B.wait" && item.completedBeforeProviderHeaders)
phase = "held-compaction"
let aFinished = false
const waitingA = wait(a.id).then(() => { aFinished = true })
// Attach a rejection handler immediately; still await the actual result later.
waitingA.catch(() => {})
for (let sample = 0; sample < 4; sample++) {
await Promise.all([
measured("held.info", () => client.server.info(options())),
measured("held.A.get", () => client.session.get({ sessionID: a.id }, options())),
measured("held.B.get", () => client.session.get({ sessionID: b.id }, options())),
measured("held.active", () => client.session.active(options())),
])
await delay(50)
}
phase = "both-held"
releaseB = gate(`primary:${b.id}`)
const beforeB = requests.length
await measured("held.B.prompt.admission", () => client.session.prompt({ sessionID: b.id, text: "Conclude while A summary is held." }, options()))
await until(() => requests.slice(beforeB).some(item => item.kind === "primary" && item.sessionID === b.id))
report.activeWhileBothHeld = await measured("both-held.active", () => client.session.active(options()))
assert(report.activeWhileBothHeld[a.id] && report.activeWhileBothHeld[b.id], "Both native executions must be active")
await measured("both-held.B.get", () => client.session.get({ sessionID: b.id }, options()))
releaseB(); releaseB = undefined
await measured("A-held.B.wait", () => wait(b.id))
const completedB = await client.session.get({ sessionID: b.id }, options())
assert.equal(completedB.outcome, "succeeded", "B must succeed, not simply stop after an error")
const messagesB = (await client.message.list({ sessionID: b.id, limit: 8 }, options())).data
assert(messagesB.some(message => message.type === "assistant" && message.content.some(part => part.type === "text" && part.text === "Synthetic conclusion")))
assert.equal(aFinished, false, "B completed independently before releasing A")
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
report.bCompletedBeforeReleaseA = true
if (!skipOutline) {
phase = "outline-under-held-compaction"
let outlining = true
const outlineStart = performance.now(), heartbeats = []
const heartbeat = (async () => {
while (outlining) {
const start = performance.now()
await client.server.info(options())
heartbeats.push({ startedMs: start - outlineStart, ms: performance.now() - start })
if (outlining) await delay(5)
}
})()
heartbeat.catch(() => {})
try {
const outline = (await measured("A-held.outline", () => client.rpc.call({ rpcID: "codenomad.session-pruning", method: "outline", location,
input: { sessionID: corpus.id } }, options()))).output
assert.equal(outline.status, "outline", JSON.stringify(outline))
assert.equal(outline.entries.length, 128)
assert(outline.entries.every(entry => entry.tools === 1 && entry.toolName === tool.name))
const elapsedMs = performance.now() - outlineStart
report.outline = { count: 128, payloadBytes: 262144, elapsedMs, heartbeats,
completedHeartbeatsDuringOutline: heartbeats.filter(item => item.startedMs + item.ms <= elapsedMs).length }
} finally { outlining = false; await heartbeat }
}
assert.equal(aFinished, false)
phase = "held-compaction"
// A genuine pending provider wait, not merely a race through a fast reply.
await delay(1_000)
await measured("held-final.B.get", () => client.session.get({ sessionID: b.id }, options()))
await measured("held-final.info", () => client.server.info(options()))
assert.equal(aFinished, false)
report.aHeldMs = performance.now() - compactStart
const releasedAt = performance.now()
phase = "summary-release"
releaseA(); releaseA = undefined
await waitingA
report.aCompletionAfterReleaseMs = performance.now() - releasedAt
const context = await client.session.context({ sessionID: a.id }, options())
assert(context.some(message => message.type === "compaction" && message.status === "completed"), "Real native compaction must finish successfully")
await until(() => events.some(event => event.sessionID === a.id && event.type === "session.compaction.ended"))
assert(!events.some(event => event.sessionID === a.id && event.type === "session.compaction.failed"))
report.eventCounts = Object.fromEntries([...new Set(events.map(event => event.type))].map(type => [type, events.filter(event => event.type === type).length]))
const business = events.filter(event => event.type.startsWith("session.") && typeof event.created === "number")
report.eventDeliveryLagByPhaseMs = Object.fromEntries([...new Set(business.map(event => event.phase))]
.map(name => [name, distribution(business.filter(event => event.phase === name).map(event => event.received - event.created))]))
report.events = events
report.latencyByOperationMs = Object.fromEntries([...new Set(measurements.map(item => item.name))]
.map(name => [name, distribution(measurements.filter(item => item.name === name).map(item => item.ms))]))
report.status = "passed"
console.log(JSON.stringify({ ...report, events: undefined, measurements: undefined, requests: undefined, assemblyWitnesses: undefined }, null, 2))
console.log(`PASS: B completed before releasing A's provider; corpus=${contextMiB} MiB, outline=${!skipOutline}`)
} catch (error) {
report.status = "failed"
report.error = String(error.stack ?? error)
throw error
} finally {
releaseA?.(); releaseB?.()
for (const value of gates.values()) value.release()
streams.abort()
await observer
child.kill()
await stopped
provider.closeAllConnections()
await new Promise(resolve => provider.close(resolve))
await writeFile(path.join(root, "server.log"), output)
await writeFile(path.join(root, "result.json"), JSON.stringify(report, null, 2))
console.log(`Private fixture retained at ${root}; only its child was stopped`)
}

View file

@ -4,7 +4,7 @@ import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { tsImport } from "tsx/esm/api"
export async function testNativeEventRelay({ client, location }) {
export async function testNativeEventRelay({ client, location, service }) {
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
const { InstanceEventBridge } = await tsImport("../packages/server/src/workspaces/instance-events.ts", import.meta.url)
const controller = new AbortController()
@ -18,8 +18,8 @@ export async function testNativeEventRelay({ client, location }) {
list: () => records,
ownsDirectory: owns,
ownsLocation: (id, ref) => owns(id, ref.directory),
getSharedServiceClient: async () => client,
subscribeToSharedService: signal => client.event.subscribe({ signal }),
getSharedServiceClient: () => service ? service.client() : Promise.resolve(client),
subscribeToSharedService: signal => service ? service.subscribe({ signal }) : client.event.subscribe({ signal }),
invalidateWorktrees() {},
}
bus.on("instance.eventStatus", event => { if (event.status === "connected") connected = true })
@ -43,6 +43,26 @@ export async function testNativeEventRelay({ client, location }) {
try {
bus.publish({ type: "workspace.started", workspace: records[0] })
await until(() => connected && nativeConnected)
if (service) {
for (const mode of ["abort", "return", "throw"]) {
const local = new AbortController()
const wrapped = (await service.subscribe({ signal: local.signal }))[Symbol.asyncIterator]()
try {
assert.equal((await wrapped.next()).value.type, "server.connected")
if (mode === "abort") {
const next = wrapped.next()
local.abort()
assert.equal((await next).done, true)
} else if (mode === "throw") {
const failure = new Error("isolated subscriber failure")
await assert.rejects(wrapped.throw(failure), error => error === failure)
} else await wrapped.return()
await client.server.info()
assert.equal((await service.acquire()).client, client, `${mode} must retain the shared native client`)
} finally { local.abort(); await wrapped.return() }
}
console.log("PASS: native service subscriber abort/return/throw retain the healthy shared connection")
}
const session = await client.session.create({ location: { directory: location.directory } })
sessions.push(session.id)
const titles = Array.from({ length: 8 }, (_, index) => `relay-fixture-${index}`)
@ -56,6 +76,13 @@ export async function testNativeEventRelay({ client, location }) {
await until(() => renames(routed.filter(x => x.instanceId === "slow").map(x => x.event)).length === titles.length)
assert.deepEqual(renames(routed.filter(x => x.instanceId === "slow").map(x => x.event)), titles)
console.log("PASS: native relay isolates slow recipients, preserves session ordering and does not block another shared SDK subscriber")
if (service) {
bridge.shutdown()
await client.session.update({ sessionID: session.id, title: "after-relay-shutdown" })
await until(() => renames(native).includes("after-relay-shutdown"))
assert.equal((await service.acquire()).client, client)
console.log("PASS: relay shutdown leaves another native subscriber and shared client requests usable")
}
} finally {
release(false)
bridge.shutdown()

View file

@ -18,7 +18,7 @@ import { testNativeWorktreeFamily } from "./test-worktree-families-native.mjs"
import { testNativeWorktreeManagement } from "./test-native-worktree-management.mjs"
import { testNativeEventRelay } from "./test-native-event-relay.mjs"
export async function testNativeLocationIdentity({ client, connection, root }) {
export async function testNativeLocationIdentity({ client, connection, root, service }) {
const { contractProfile, runtimeIdentity } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
const { locationRequestOptions } = await tsImport("../packages/server/src/opencode/compatibility/location.ts", import.meta.url)
const profile = connection.profile ? await connection.profile() : contractProfile(runtimeIdentity(connection.endpoint))
@ -36,7 +36,7 @@ export async function testNativeLocationIdentity({ client, connection, root }) {
const rootLocation = await client.location.get({ location: { directory } })
const worktreeLocation = await client.location.get({ location: { directory: worktree } })
assert.equal(rootLocation.project.id, worktreeLocation.project.id)
await testNativeEventRelay({ client, location: rootLocation })
await testNativeEventRelay({ client, location: rootLocation, service })
await testNativeWorktreeFamily({ client, profile, rootLocation, worktreeLocation })
await testNativeWorktreeManagement({ client, root })
if (profile !== "legacy") {

View file

@ -0,0 +1,57 @@
import { execFileSync, spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
// Read-only source A/B in isolated loopback Chromium fixtures. No checkout,
// desktop, daemon, database or user-profile mutation is needed.
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const ui = join(root, "packages/ui")
const reference = "47f9e43e3fa1a3be59c0f94932a7af0a4be20660"
const source = "packages/ui/src/components/tool-call/renderers/task.tsx"
const repetitions = Number(process.argv[2] ?? 3)
if (!Number.isInteger(repetitions) || repetitions < 1 || repetitions > 10) throw new Error("Use 1–10 repetitions")
const directory = mkdtempSync(join(tmpdir(), "codenomad-render-cost-"))
const baselinePath = join(directory, "task-baseline.tsx")
writeFileSync(baselinePath, execFileSync("git", ["show", `${reference}:${source}`], { cwd: root }))
const runs = []
for (let repetition = 0; repetition < repetitions; repetition++) {
for (const variant of ["baseline", "fixed"]) {
const env = { ...process.env, CODENOMAD_RENDER_INSTRUMENT: "off" }
if (variant === "baseline") env.CODENOMAD_RENDER_TASK_BASELINE = baselinePath
else delete env.CODENOMAD_RENDER_TASK_BASELINE
const result = spawnSync(process.execPath, ["--import", "tsx", "--test", "--test-concurrency=1",
"--test-name-pattern=^audit sample:", "tests/browser/render-cost.test.ts"], {
cwd: ui, env, encoding: "utf8", windowsHide: true, timeout: 120_000,
})
writeFileSync(join(directory, `${repetition + 1}-${variant}.log`), `${result.stdout ?? ""}\n${result.stderr ?? ""}`)
if (result.status !== 0) throw new Error(`Fixture failed (${variant}): ${result.error ?? result.stderr}; logs: ${directory}`)
const match = result.stdout.match(/render-cost-native-deltas (\{[^\r\n]*\})/)
if (!match) throw new Error(`No metrics in ${variant}; logs: ${directory}`)
const metrics = JSON.parse(match[1])
if (metrics.variant !== variant || metrics.instrumented || metrics.samples.length !== 10) throw new Error("Invalid A/B fixture metadata")
runs.push({ repetition: repetition + 1, ...metrics })
console.log(`${repetition + 1}/${repetitions} ${variant}: 10 samples recorded`)
}
}
function stats(values) {
const sorted = [...values].sort((a, b) => a - b)
const mean = values.reduce((sum, value) => sum + value, 0) / values.length
return { n: values.length, median: (sorted[(sorted.length - 1) >> 1] + sorted[sorted.length >> 1]) / 2,
min: sorted[0], max: sorted.at(-1), mean,
sampleStandardDeviation: Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (values.length - 1)) }
}
const summary = Object.fromEntries(["baseline", "fixed"].map(variant => {
const samples = runs.filter(run => run.variant === variant).flatMap(run => run.samples)
return [variant, Object.fromEntries(["syncMs", "scriptMs", "taskMs", "layoutMs", "styleMs"].map(key => [key, stats(samples.map(sample => sample[key]))]))]
}))
const artifact = { reference, fixedSourceSha256: createHash("sha256").update(readFileSync(join(root, source))).digest("hex"),
browserPath: process.env.CODENOMAD_BROWSER_PATH ?? "Playwright default", node: process.version,
repetitions, scenario: "80 completed read steps × 8,000 characters; 3 warmup deltas, 10 single native text deltas per run",
notes: "CDP cumulative CPU deltas through two animation frames; not frame latency. Instrumentation off. Other host activity is not controlled.",
summary, runs }
writeFileSync(join(directory, "metrics.json"), JSON.stringify(artifact, null, 2))
console.log(JSON.stringify(summary, null, 2))
console.log(`Raw metrics and logs: ${join(directory, "metrics.json")}`)

View file

@ -165,7 +165,7 @@ try {
assert.equal((await client.server.info()).version, runtimeVersion)
console.log(`Testing official runtime ${runtimeVersion}`)
const { testNativeLocationIdentity } = await import("./test-opencode-location-native.mjs")
await testNativeLocationIdentity({ client, connection, root })
await testNativeLocationIdentity({ client, connection, root, service: sharedService })
const { testNativeProxy } = await import("./test-opencode-proxy-native.mjs")
await testNativeProxy({ client, baseUrl, root, runtimeFetch, connection, authorization: `Basic ${Buffer.from("opencode:isolated-pruning-fixture").toString("base64")}`,
exercise: async (proxy, sessionID) => {