fix(web): keep one composer bar DOM across the no-workspace transition
The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.
test(web): add the composer DOM-continuity acceptance probe
Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.
docs(web): session-maybe identity is adoption, not hold-forever
fix(web): session-maybe entries adopt the first session, then remount like strict entries
A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.
chore: knip knows the root acceptance probe's playwright dependency
scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.
Documentation only; no behavior change.
`firstLiveSeq`'s JSDoc still stated the boundary sits at that seq
unconditionally. Verified reachable on a plain production path: pick up a log,
do no work, pick it up again — the seed already ends in a boundary, so it is
not re-marked and `events[firstLiveSeq]` is undefined. Both that claim and the
firehose-gap sentence are now conditional, with the locate rule ("scan for the
last boundary") matching what types.ts already said.
`repair.ts`'s header claimed this module supplies the boundary. It does not —
the constructor does; this module supplies the activity read that must skip it.
Reworded, and it now names the closer timestamp leak, which is the one real
coupling that justifies `lastActivityTime` living beside the repair synthesis.
Recorded that `Session`'s constructor is the boundary's only legitimate writer,
since the invariant companion constrains nothing and a plugin-side append would
silently turn live brackets below it into dead history.
Drop the explicit-undefined base fixture exactOptionalPropertyTypes
forbids (the repository trusts TypeScript at typed same-process seams —
no test for an input the static interface excludes; coverage holds), and
reshape the async-listener containment fixture as an unknown-returning
function: the earlier inline cast was silently stripped by the staged
oxlint fixer, leaving a shape the next lint pass rejects.
The seam README states the JSON-shaped write boundary, watch-disposer
quiescence, async listener containment, and the drained teardown; the
provider README rewrites Behavior around the operation chain,
read-modify-write, writer lock, ready reconcile, and leaf-level YAML
diffs, and updates Known Limitations to the residual guarantees.
A new Agent Note records the round's decisions and supersedes the
original note's deferred-lockfile alternative (cross-linked in place).
Chinese counterparts updated pair-by-pair (three briefed minimal
updates, one whole-document translation); type-equiv, config, cordis,
and module-graph catalogs re-recorded.
- `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime",
which stopped being true for attached sessions.
- The core invariant let `session/inherited` fall through the merge-extensible
default. It is core-owned, so it gets an explicit case; an unbalanced seed
legally places it inside an open turn, which the relation permits.
- The Agent Note claimed the boundary reaches disk via `live.pending`/
`scheduleDrain`. Verified false: the constructor append precedes `enter()`,
so it never publishes on `session/event` and rides the creation seed instead.
Attaching is therefore a write where none happened before — recorded, since
only `load()` stays a pure read.
- The deferred-index proposal asserted this change documented the cold-mtime
skew on `dsh-host-apiproxy`. It did not; the README entry now exists.
- `firstLiveSeq`'s firehose gap runs through its own seq, not below it.
- The boundary is not always at `firstLiveSeq` (the idempotence guard), so
consumers scan for the last one.
- `lastActivityTime` excludes by type, so a pickup time still leaks onto a
synthetic closer when a boundary ends an open turn. Documented.
- Pin the fork claim end-to-end: a child inherits a still-running parent's
open bracket below its own boundary, while the parent has none. Fails if the
write moves back to the load path.
- Fix the telemetry title that contradicted its own assertions.
The `/status` call site cannot be pinned the way the other two are: the
command appends its own `command/run` before rendering, so the boundary is
never the log tail there. Its fixture now at least renders over a
boundary-bearing log.
Review round three found the provider's write path could destroy state it
never observed:
- Watcher reloads and document writes ran on two independent promise
chains, and a write rendered the whole next document from the cached
text. An external edit still inside the debounce window (or missed
outright) was overwritten, and the follow-up reload no-oped because the
post-rename content matched the cache — the edit vanished without a
trace. Reloads and writes now share one operation chain, and every write
starts by reconciling the on-disk text into the seam before rendering,
so unobserved sibling sections survive and publish first. An unparsable
on-disk document fails the write loud instead of being overwritten.
- The initial load raced the watcher's own setup: a change written between
that read and the watcher becoming active never fired an event. The
watcher's ready signal now queues one reconcile, closing the gap.
- Two processes sharing a harness home rendered from independent caches,
last writer winning. Writes now hold a wx-created <file>.lock sibling
around the read-render-rename cycle with bounded backoff, a crashed-
holder stale takeover, and a deadline failure; readers stay lock-free
because the rename commit is atomic.
- renderYaml replaced the whole namespace node, dropping every comment
inside the section. The next section now lands as a leaf-level diff
(set changed values, delete removed keys), so comments, anchors, and
formatting survive on every untouched node and on the key of every
changed pair; arrays still replace wholesale when unequal.
Review round three found four seam defects:
- A watch() disposer only removed the observer from the set; an invocation
already chained onto the watcher tail still ran after disposal. Watchers
now carry an active flag checked when a queued invocation would start,
and the service dispose drain awaits started invocations (pendingTails)
beside the write queues, so disposal is quiescent.
- The settings/updated manual fan-out caught only synchronous throws; an
async listener rejection escaped as an unhandled rejection. Thenable
returns are now contained through the shared listener diagnostic, and
the event contract documents that the INVARIANT rethrow serves
synchronous listeners only.
- structuredClone admitted Dates, Maps, BigInts, and cycles that YAML/JSON
storage silently distorts on reload (a Date lands as a timestamp string,
a Map as a plain map, a BigInt as a number). The write snapshot is now a
single-pass cloneJsonShaped walk that rejects non-JSON values with their
path before anything persists.
- mergeLayers' per-entry undefined guard became dead code once the clone
strips undefined entries at the boundary; removed, with the sparse-patch
contract restated at its enforcement point.