Conversation
Issue 20: the session environment, researched NOTHING WAS REPAIRED. This adds one document under docs/research/ and changes no file under src/. No Electron was launched, PR #21 was read and never merged, commented on or closed, and no variable's value appears anywhere in the document. The report is two complaints with different answers. The title's premise is false, and it is false in the direction of good news. A session is already not a subprocess of Tortie. Measured on his own machine: a pane's parent is the private tmux server and the server's parent is launchd, with the Tortie that ran new-session nowhere in the ancestry. That is the durability invariant working as designed, and it is why quitting Tortie does not end his sessions. What he is actually feeling is the long-lived server's frozen boot environment, which a Tortie restart does not refresh because a Tortie restart does not restart the server. The live server's global environment holds 17 names, all launchd-shaped, and its PATH is 35 entries with no tilde and nothing relative. The second half is real, and it is Phase 33's problem statement word for word. launch.envPassthrough shipped, works, resolves values per launch and writes none of them to the manifest, and is set by no compiled agent row. So a provider key exported in ~/.zshrc reaches no freshly created agent pane on any machine until the person writes an agents.json, restates launch.argv and passes the confirm gate. The mechanism exists and is inert everywhere. The real finding is the third thing, and it is ours rather than his. When an agent pane starts without the environment it needs, Tortie says nothing at all. The env-unresolved notice is structurally unable to fire for him, because both call sites are guarded on the row already naming variables; Settings draws nothing when there is no config file, which hides the only sentence that names agents.json; and agents.json appears in exactly one renderer string in the whole app, inside that hidden block. Phase 33 built the mechanism and shipped no path to it, so he got pi's own model error and silence from us. PR #21: decline changes 1 and 2, adapt change 3 as its own entry. The login shell flag is gated to agent === 'shell' at both call sites, so -il never reaches pi; a shell pane is already interactive, measured; -i is a regression on the nine harness zsh -l -c sites, 68 ms to 1714 ms with a banner prepended to stdout; and four tests go red, one of them substantive, under which an existing shell session silently comes back running a different command. Expanding ~ inside captured PATH entries is the one genuinely new thing in the diff and nothing in the tree does it today, but it is unrelated to the reported symptom and produces command-not-found rather than a model error. Two drifts are reported and left alone: the probe deadline is 10 seconds while the shipped guide, the Phase 33 backlog entry and a code comment all say 3, and research 41 section 11 has three open items this document closes. No phase is queued off this. That waits for his word.
One running-log line, appended at the end, newest last. Records that issue 20 was researched to a document and that no source file moved: the title's premise is false because a session's parent is already the private tmux server rather than Tortie, the staleness is the server's frozen boot environment, the second half is Phase 33's problem statement with the shipped mechanism set by no compiled row, the real finding is that Tortie says nothing when a pane starts without its environment, and PR #21 declines two changes and adapts the third. No phase is queued off this. That waits for his word.
Phase 276: ask the shell once Phase 275 gave a person one shared list of shell variable names for every agent, and sent them a bill for it. A shared name applies to EVERY agent, so the login-shell probe that used to fire for the one agent somebody had configured now fires for all of them, on every create. This commit is the second half of that phase's own bill. THE COST IS ONE FLAG AND NOT THE SHELL, and the measurement is the whole argument. On the operator's machine, three runs each doing identical trivial work, `zsh -lic` read 1160, 970 and 980 ms while `zsh -lc` read 10, 10 and 10 ms. A hundredfold, and all of it is the `i`: his `~/.zshrc` sources oh-my-zsh, nvm and rbenv, and those load for an interactive shell alone. Re-derived once more at commit time on a calibrated slow home whose .zshrc sleeps 900 ms, by a different method from anything the phase instruments, `zsh -lic` read 960, 940 and 940 ms and `zsh -lc` read under 10 ms three times — and `-lc` did NOT see the .zshrc export while it did see the .zprofile one. THE `i` IS LOAD-BEARING AND IT STAYS. `.zshrc` is read by interactive shells and by nothing else, and a provider key exported there is the whole of issue 20. Dropping the flag makes the probe fast and the feature useless, which is the mistake PR #21 made. So the second is not removed. It is paid ONCE. THE HEADLINE, MEASURED AT THE PARENT AND AT HEAD BY THE SAME PROBE `probe:p276` launches one Electron on a scratch profile with a scratch HOME whose .zshrc sleeps 900 ms, and points `$SHELL` at a wrapper named `zsh` that appends one line per login shell and then execs the real one. So the headline is counted from OUTSIDE the app rather than from anything the app reports. At the parent, nineteen creates, every one of them 958 to 1008 ms, and 22 login shells for the run. The six warm creates started 5 login shells and the create for a second agent started 1. At HEAD, the cold create is 51 ms and the six warm creates are 25, 23, 20, 19, 20 and 21 ms. A create for a SECOND agent is 18 ms. Sixteen login shells for the whole run, and ZERO of them for the cold create, ZERO for the six warm creates, ZERO for the second agent and ZERO for a settings write that is not a shell variable. THE KEY IS COVERAGE, AND THAT IS THE LOAD-BEARING HALF There is one slot in `src/main/tmux/resolve.ts`, beside the PATH memo it is modelled on. An ask is answered from it if and only if the slot holds every name the ask mentions, and the answer is projected onto the ask. A name a person has just added in Settings can therefore never be served out of an answer that does not mention it — no listener, no event and no timer takes part in that, it is a property of the read. Equality would have been the obvious key and it is wrong twice. Nothing at boot knows which agent a person is about to create, so a warm-up's set would equal no later ask and every create would miss. And two agents with different per-agent lists would evict each other. A miss therefore probes for the UNION of the ask and the slot, which is free: on the calibrated slow home `zsh -lic` for ONE name read 811, 818, 828, 834 and 838 ms and for FIFTY-TWO names read 918, 853, 850, 854 and 839 ms, because the cost is the shell start and not the number of printfs. Past 256 names the slot is replaced by the caller's own names rather than grown, never partially merged and never truncated. The projection hands out a fresh object every ask and iterates the CALLER's list in the caller's own order, because `create-local.ts` aliases what it is handed and because Phase 275 promised the `-e` argv order does not move for a person who never touches the shared list. A FAILED PROBE IS NEVER CACHED AS SUCCESS `captureLoginShellEnv` signals failure by VALUE and not by rejection, so the naive memo would remember a failure for the life of the process and every session for the rest of the run would launch with no values — Phase 269's silent, provider-shaped failure re-created by our own optimisation. A result with `probeFailed: true` is returned to every waiter and installed nowhere, the in-flight pointer is cleared so the next ask is a fresh attempt, and nothing is disarmed, dropped or re-warmed, because a shell that timed out once under load is not a reason to throw away an answer that is still true. Driven: an rc that hangs was killed at the 10,004 ms deadline, the slot held nothing afterwards, and the next create recovered with the right value. A PARTIAL answer is cached, and the argument is written into the code: a stale miss is self-announcing, because it produces the `env-unresolved` notice naming the variable on every create, and a stale hit says nothing at all. A probe that a drop overtook installs nothing either. That race is one this phase CREATES rather than inherits — the PATH memo next door has no production invalidator and its equivalent has never fired — so the generation is stamped before the probe starts and compared when it settles. THE WATCH, AND THE fs.watch TABLE IT WAS BUILT FROM `src/main/env/watch.ts` holds `node:fs`'s own watch, non-recursive, over the files the person's shell actually reads, derived from `$SHELL` rather than hardcoded. Measured, one scratch directory, a 1.5 s settle per step: a rename-over gave the FILE watch 1 event and the DIRECTORY watch 2; the next edit gave the file watch 0 and the directory watch 1; the edit after that, the same. So a file watch goes deaf after the rename every editor does. `fs.watch` on a path that does not exist threw ENOENT, and the directory arm caught the later creation at +11 ms. With `~/.zshrc` a symlink into a dotfiles repo the `$HOME` directory watch saw 0 events, because nothing in `$HOME` moved, and the REALPATH arm saw it. So both arms, deduped by directory and basename. Non-recursive is the whole answer to cost. Over a home-shaped tree of 2,000 writes under `Library/Caches` and `node_modules` plus ONE edit to `.zshrc`, `@parcel/watcher` delivered 2,001 events and `fs.watch` delivered 1, and both caught the edit. On the operator's real home, read only, one non-recursive `fs.watch` delivered 0 events in 30 seconds and 5 in 120 seconds, none of them an rc basename. `src/main/watcher/` is refused for a second reason: every subscription it makes carries the FSEvents exclusion plan, which accepts at most eight paths and applies ZERO at nine, so a home-directory subscription inside that budget has silent total failure of every other repository's exclusions as its overflow mode. `conformance:watcher` runs anyway to prove the budget is untouched. THE ROTATION, READ OUT OF THE PANE'S OWN ENVIRONMENT Eleven save shapes, each one followed by a real session whose stand-in agent read its OWN environ and wrote down which GENERATION of an invented sentinel it received. Never a value, and the sentinels are invented names against invented providers. append in place gen-2 arrived truncate and rewrite gen-3 arrived rename-over gen-4 arrived unlink and recreate gen-5 arrived a file created for the first time, watched though it did not exist gen-6 a write through a symlinked path gen-6 an edit to a dotfiles-repo target inside the home gen-8 touch with no change value unchanged either way a file the rc SOURCES STALE at gen-1, as declared then Re-read shell gen-2 delivered a name added in Settings while the app ran gen-13 The sourced row is the one the watch provably cannot see: the rc itself never moved and the sourced file is one directory down from a non-recursive watch, and it started 0 login shells for the edit. That is why there is a button. FIVE CONCURRENT CREATES START ONE LOGIN SHELL. The in-flight pointer is assigned synchronously before any await, so there is no window in which two callers see an empty one, and two callers sharing a capture each project onto their OWN list, so one create never sees another agent's names on its `-e` line or in its notice. THE REFRESH IS A VISIBLE CONTROL, SO THE SEMVER IS MINOR The entry said patch unless the refresh became visible, and it became visible. Settings, Launch defaults, a right-aligned [Re-read shell] in the section toolbar, drawn only when this person has named a shell variable somewhere. Its whole explanation is behind hover — "Ask your shell again. The next session you start gets the current values." — because "Just enough words" puts explanation a person might want behind hover rather than on the resting face. It says the NEXT session and never this window, because pressing it reads nothing into the window: the channel resolves with nothing at all, not a name, not a count and certainly not a value. A button that returns nothing can never lie. There is no age line, following SpecStory rather than Agents, because an age climbing while you watch it turns a control a person almost never needs into a nag. ONE DEVIATION FROM THE SPEC, AND IT IS IN THE CSS. The spec said this phase would add no CSS rule. It adds one declaration, `.set-toolbar-end`, because `.set-section-toolbar` is `display: flex` with no justification and does not right-align on its own: the Agents section gets that from its age line's `margin-right: auto`, and the SpecStory section spells the same declaration locally as `.ss-toolbar`. This is the third instance of the shape and the second with nothing to its left, so it is written once in `settings.css` rather than a third time in a third file. THE WARM-UP IS OFF THE BOOT PATH, MEASURED WITH IT ON AND OFF Two steps and one chain, a sibling of the Phase 208 login observe rather than a link in it. Arming is synchronous and happens as soon as the core is open, because arming early is what buys the restore burst: with the cache armed the first restored session's probe fills the slot and every session after it hits, where the parent has twenty restored sessions pay twenty login shells. The PROBE then waits the same second the login observe waits, and nothing awaits either step. Twelve launches, six with the feature on and six with `GMUX_NO_ENV_CACHE=1`. `window-shown` read 402.6, 425.5, 412.0, 390.7, 419.6 and 424.4 ms with it on against 408.8, 402.4, 449.2, 405.8, 403.3 and 411.8 ms with it off — medians 419.6 and 408.8 ms, which is inside the spread. A second run of the same arm read medians 380.8 on and 390.8 off, in the other direction. `path-ready`, the control that SHOULD move, read 1377.8 to 1458.4 ms on against 1400.6 to 1447.9 ms off. The feature-on arm started 2 login shells per launch against 1, so the warm-up definitely ran. NO VALUE IS PERSISTED ANYWHERE The rule is Phase 269's and keeping the answer longer tightens it rather than loosening it. The probe swept the app's own output for the sentinel values after the whole run: 72 profile files scanned and 0 hits, 0 in the manifest, 0 in the log, 0 in settings.json, 0 in the pane report and 0 in the harness's own stdout. `loginShellEnvNamesHeld()` is the only door out of the slot and it hands back NAMES; there is no exported function, field or channel through which a value can leave it. THE CACHE IS OFF UNTIL SOMETHING IS WATCHING IT `envCacheArmed` starts false, and only the watcher turns it on, after it has derived a watch set and opened at least one handle. While it is false, `loginShellEnvFor` IS `captureLoginShellEnv`, byte for byte the parent's behaviour, down to refusing even the in-flight join. So every way the watcher can fail to build itself — an unrecognised `$SHELL`, a home no watcher can open, a `ZDOTDIR` outside the home, a quit that overtook the boot chain — lands on today's cost rather than on a cache nothing can invalidate. That is a proof rather than a convention: a stale key needs the cache on, and the cache being on needs the invalidation armed. And the per-create probe stays as the FALLBACK on every one of those paths, because a session that starts without a key a person set is worse than a session that takes a second. THE REMOTE PROBE DOES NOT CACHE AND THIS PHASE DID NOT TOUCH IT. Its own header already ruled it. The cost being paid down here is on this Mac, a cache keyed without the machine would hand one machine's values to another, and the half that makes the local cache safe does not exist over there, because nothing can `fs.watch` across ssh. `REMOTE_ENV_ALLOWED` is still exactly two names and no value is ever sent from this Mac. WHAT THE VERIFIERS DID THAT THE BUILDERS DID NOT One wrote its OWN rig against the shipped modules over a real scratch HOME with a real zsh, and attacked the invalidation with shapes no builder tried: `sed -i` with its backup-and-rename, a rapid burst of edits, an rc that exits non-zero, a `$SHELL` that does not exist, an rc that hangs past the deadline, a rotation landing in the middle of a probe, and a dotfiles target OUTSIDE the home, which must read stale and did. Another measured the PARENT commit with the same app probe, which is where the 958-to-1008 ms row above comes from, and the parent arm failed on exactly the three findings that prove the phase: the warm creates started 5 login shells, the second agent started 1, and the sourced row invalidated because a parent with no cache reprobes every time. A third re-derived the `-i` ratio and the "widening is free" claim with its own spawner rather than the phase's instrumentation, and the two agreed. THE GATES `conformance:shellenv` is new and drives the shipping cache and the shipping watcher over their own seams — a counting fake for the capture, a fake directory watcher fired by hand, a fake clock so the 400 ms debounce and the 5,000 ms floor are read as numbers rather than waited out — and then reads the refusals out of the tree. It spawns no shell, which is the point rather than a convenience. `ablation:p276` is the attack beside it: 32 ablations, one clause each, every one red on the rule that owns it, measured as a DELTA against the base so an inherited failure cannot be mistaken for a caused one. typecheck, build, test at 913 files and 14,496 tests, smoke:t1 6/6, smoke:t3 3/3, package signed, conformance:shellenv, ablation:p276, conformance:watcher, conformance:resume:capture, conformance:credentials, and the menu gates inside build, which prove the native menus did not change. `HELPER_USER_FLOOR` 137 to 138 for the new probe, `RUNNER_CALLER_FLOOR` 48 to 50, and the contract baseline regenerated for one added read-only channel, `settings:envRefresh`, 233 to 234, and two environment names, `GMUX_ENV_WATCH_SEED` and `GMUX_NO_ENV_CACHE`, 111 to 113. ONE THING THE HARNESS NEEDED, AND IT IS THE INTEGRATOR'S FINDING. The boot chain sits below `dispatchHarness`, which is the right position and does not move, so a driven app run never reaches it. The first run of `probe:p276` read ONE login shell and about 960 ms for every one of eighteen creates — the parent's behaviour exactly — because nothing in a harness launch arms the cache. `src/main/harness/env-watch-seed.ts` calls the SHIPPED pair, in the shipped order, behind the same two refusals the other seeds carry, and seeds no value of its own. THE LIMITS, PLAINLY A key exported by a file the rc SOURCES, one a vault hands over at shell start, one a plugin loads from a `.env`, or one the shell INHERITS does not move anything we watch, and needs the button. A dotfiles target outside the home tree is not watched at all, by this phase's own refusal. A person who really has `ZDOTDIR` set to the empty string reads as unset through the probe, so the automatic invalidation is blind for them; the cache is still correct, because coverage and the refresh both work, and only the convenience is lost. `fish` is not in the shell table, because it is not installed on the machine this was built on and an unmeasured row would arm a cache nobody has driven; a fish user keeps today's cost and can never be stale. A bash login shell never reads `.bashrc` at all, which is a pre-existing gap and not this phase's. An invalidation revises nothing already running: the value left this process on the `-e` argv and now lives in the tmux server's session environment, so the promise is about the NEXT session and always was. And the deliberate refresh spawns one login shell, which is exactly what a person who pressed it asked for. A person who has named no shell variable spawns nothing here at all, at boot or ever.
|
Closing this one — the substance of it shipped in 0.107.0, by a different route, and I've written the full account on #20 so it's in one place. Short version. Your interactive-shell instinct was right and is in there: Tortie uses an interactive shell for the probe that reads your variables, rather than for every session. It couldn't go on the session path — measured, an interactive shell costs about a second against ten milliseconds for a login shell. And this version wouldn't have reached pi anyway: The Thank you for opening it, and for saying plainly that it was untested. That was the right call. |
Closes #20.
Pi was started inside a Tortie-managed shell session whose environment did not reliably match a normal terminal. In particular, shell startup files exporting Pi provider keys could be skipped, leaving Pi with only cached/Codex models; Tortie also retained literal
~components in the captured PATH.This changes shell sessions to interactive login shells so the normal shell environment is loaded, expands
~PATH entries during capture, and keeps restart handling compatible with existing-lsessions.Not tested: Vitest is unavailable in this checkout. I do not know whether this fixes the issue.