feat(workspace): F2 dispatch-seam freeze — inert branch hooks + ContextVar + op-vocab (#168)#214
feat(workspace): F2 dispatch-seam freeze — inert branch hooks + ContextVar + op-vocab (#168)#214pzhang15 wants to merge 2 commits into
Conversation
Phase 0 / F2 foundation freeze. Adds dead-until-Lane-A hook seams to the VFS dispatch chokepoint so the write-layer (B/C) and commit (D) lanes plug in without ever reopening dispatcher.py. With no branch bound, behavior is byte-for-byte unchanged (every hook is gated on `branch is not None`, and nothing binds a branch yet). - observe/context.py: add the _active_branch ContextVar trio (push_branch / reset_branch / branch_for), mirroring _revisions; typed object | None so the real Branch class (Lane A) need not be imported. - workspace/branch_vocab.py (new): canonical READ_OPS / WRITE_OPS, the single source of truth all lanes import; dispatcher's _DISPATCH_*_OPS now re-point here (byte-identical frozensets). - workspace/dispatcher.py: inert hooks in dispatch() -- branch_for() lookup, the share-by-ref _needs_staging() gate (keyed on whether the resource overrode BaseResource.fork, NOT is_remote, so a Redis mount with is_remote=False is still staged), and staged_read / divert_write calls on the bound branch; plus a policy_for() stub. Documents the invariant that dispatcher.py is now read-only for all downstream lanes. Tests: branch-trio unit tests; _needs_staging across RAM (False) / Redis (True, is_remote=False) / S3 (True); branch_vocab contents + re-export identity. Full existing suite stays byte-for-byte green (only pre-existing environmental failures: test_tac, one redis test). Stacked on F1 (#206 / feat/phase0-f1-fork), which is not yet on main; retarget the PR base to main once F1 merges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-review — F2 dispatch-seam freezeDid a thorough pass over this PR (against the head commit, on the ✅ Confirmed sound (no action)
🟡 Design / contract completeness (the ones worth deciding now)1. The op-vocab makes the frozen contract incomplete for metadata + directory ops. The seam only diverts Separately, The freeze's invariant is "extend solely via
2. 3. 🟡 Tests4. The active routing block is never executed by any test. The new behavior the freeze exists to lock — 5. (nit) ⚪ Nits6. Inside 7. 8. The " Automated self-review via Claude Code — multi-lens pass with an adversarial verification stage. Findings are prioritized; the design questions (1–3) are the ones I'd most like a second opinion on before merge. |
Self-review follow-ups on the dispatch-seam freeze (no behavior change with no branch bound; full regression green): - Scope the frozen invariant honestly (Finding 1). The seam covers content read/write routing only. stat/readdir/find flow through dispatch but aren't in the op-vocab, so with a branch bound they fall through to live (content-only staging; metadata stays live); and mkdir/rmdir/rename run via mount.execute_cmd and never traverse dispatch at all. The Dispatcher docstring now says this instead of claiming blanket "read-only for all lanes." - Add an executable routing guard (Finding 4). A _FakeBranch + four tests via push_branch assert: read_bytes -> staged_read, write -> divert_write, a non-vocab op (stat) falls through to live, and an isolating (RAM) mount bypasses the branch even for a read. Turns the frozen contract into a regression guard. (Supersedes the original "no fake branch" note; the fake is routing-only and constrains nothing about Lane A's implementation.) - Doc/consistency (Findings 2,3,6,7,8): _needs_staging documents that it tests override-identity (an approximation; fail-closed deferred to F4); policy_for clarified as a name-reserving stub resolved at commit time (not a per-op call site), public asymmetry noted; the seam uses the _DISPATCH_* aliases like its neighbors; the invariant is stated once in the class docstring with the inline comment trimmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-review follow-ups on the dispatch-seam freeze (no behavior change with no branch bound; full regression green): - Scope the frozen invariant honestly (Finding 1). The seam covers content read/write routing only. stat/readdir/find flow through dispatch but aren't in the op-vocab, so with a branch bound they fall through to live (content-only staging; metadata stays live); and mkdir/rmdir/rename run via mount.execute_cmd and never traverse dispatch at all. The Dispatcher docstring now says this instead of claiming blanket "read-only for all lanes." - Add an executable routing guard (Finding 4). A _FakeBranch + four tests via push_branch assert: read_bytes -> staged_read, write -> divert_write, a non-vocab op (stat) falls through to live, and an isolating (RAM) mount bypasses the branch even for a read. Turns the frozen contract into a regression guard. (Supersedes the original "no fake branch" note; the fake is routing-only and constrains nothing about Lane A's implementation.) - Doc/consistency (Findings 2,3,6,7,8): _needs_staging documents that it tests override-identity (an approximation; fail-closed deferred to F4); policy_for clarified as a name-reserving stub resolved at commit time (not a per-op call site), public asymmetry noted; the seam uses the _DISPATCH_* aliases like its neighbors; the invariant is stated once in the class docstring with the inline comment trimmed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e5138dc to
9a0788c
Compare
Overview
Phase 0 / F2 — the dispatch-seam freeze (#168, epic #166, roadmap #165).
This is a foundation freeze, not a feature. It adds dead-until-Lane-A hook seams to the one VFS chokepoint (
workspace/dispatcher.py) so the write-layer lanes (B/C) and the commit lane (D) plug in by providing objects the hooks call — never by reopeningdispatch(). With no branch bound, behavior is byte-for-byte unchanged: every hook is gated onbranch is not None, and nothing binds a branch yet (that's Lane A / #171), so all new branches are unreachable today.Detailed changes
observe/context.py— the_active_branchContextVar trioMirrors the existing
_revisionstrio exactly:_active_branch: ContextVar[object | None](defaultNone) — typedobject | None; the realBranchclass is Lane A's and is deliberately not imported (would be a cycle).push_branch(branch) -> Token,reset_branch(token),branch_for() -> object | None.Task-isolated, same as
_revisions.workspace/branch_vocab.py(new) — the frozen op-vocabularyThe single source of truth all lanes import:
READ_OPSandWRITE_OPS. Leaf module (no workspace imports → no cycle).dispatcher.py's_DISPATCH_READ_OPS/_DISPATCH_WRITE_OPSnow re-point here (the frozensets are byte-identical to the originals, so existing call sites are unchanged).workspace/dispatcher.py— inert hooks indispatch()branch = branch_for()near the top (Nonetoday)._needs_staging(mount)— the share-by-ref classifier:type(mount.resource).fork is BaseResource.fork. A resource that did not overrideBaseResource.forkshares its live backend by reference across a fork and must be staged. Deliberately NOT keyed onis_remote— a Redis mount isis_remote=Falseyet still shares by ref. (RAM/cache/Disk overridefork→ isolate → no staging; Redis/S3/Slack/GDrive inherit the default → need staging.) A comment notes F4 ([P0] F4: Write-layer protocol + resource strategy seam + store flags #170) may swap this predicate's internals but not its call site.branch.staged_read(mount, path, **kw)) and a write-divert hook (branch.divert_write(mount, op, path, **kw)), both insideif branch is not None and _needs_staging(mount):and gated by op-vocab. Both return(result, IOResult)likedispatch. These method names are the contract Lane A implements.policy_for(mount)stub returningNone(documented for F3/F4/Lane D to fill).Everything below the guard is the existing dispatch logic, unchanged.
commands/builtin/cross_mount.py#168 mentioned fixing this dead module's "op-name mismatch." It was deleted on
mainby Lane F (#186) — but F1 predates that deletion, so the file is still present on this stack's base. F2 does not touch it: its dispatch calls already use canonical op names (read_bytes/write_bytes/unlink), so there is no mismatch on this base, and the deletion is Lane F's to carry. It drops out when this stack rebases onto post-#186main.Tests
tests/observe/test_context.py— branch trio:branch_for()isNoneby default; push → returns it → reset restores previous; nested push/reset.tests/workspace/test_branch_vocab.py— vocab contents, disjointness, and that the dispatcher re-exports the same set objects.tests/workspace/test_dispatcher.py—_needs_stagingacross RAM (False), Redis (True,is_remote=False), S3-style remote (True), pluspolicy_fordefault. (Redis's real__init__connects to a live server, so the test builds the realRedisResourcetype viaobject.__new__— the gate only inspectstype(resource).fork, keeping the test hermetic.)tests/fuse, which needs native libfuse) passes with no new failures; the only failures are pre-existing/environmental (test_tac.py— no systemtacon macOS; one redis test — noredis-server).Definition of Done (#168)
push_branch/reset_branch/branch_foradded toobserve/context.py, mirroring_revisions, with a unit test.is_remote=False, share-by-ref) is classified as needing staging — explicit_needs_stagingtest across RAM/Redis/S3.dispatcher.pyneeds no further edits from any other lane (stated in theDispatcherdocstring + here).pre-commit run --all-filesclean and relevant tests green.The invariant
dispatcher.pyis now read-only for all downstream lanes. Lanes A–J extend dispatch behavior solely by providing the boundbranchobject'sstaged_read/divert_writemethods (Lane A) — they never reopendispatch(). The seam call sites are frozen; F4 (#170), a sibling Phase-0 sub-issue, may refine the_needs_stagingpredicate internals and flesh outpolicy_for, but not the call sites.🤖 Generated with Claude Code