The provider's Linux decisions now live in LinuxScopeSelector, a state machine
over injected observations: the deep probe, the user-manager listener, the
manager confirmation, and the systemd-run binary identity. Each decision
carries its reason and probe trigger, the runtime logs it at debug level, and
table-driven scenarios cover every transition.
- A positive verdict probes again when the systemd-run found through PATH
changes device, inode, size, or modification time, so an in-place upgrade
past v254 cannot reuse options that no longer keep argv literal.
- The listener is read before the probe, so a manager that starts during a
failed probe counts as a new listener.
- The kernel socket table is decoded as UTF-8, matching runtime directories
outside ASCII.
- LinuxScopeNotStartedError drops its unused code field.
A cached positive verdict let a hung user manager that still listens fail every
later command, and a cached negative verdict ignored a manager that started
later until the provider restarted.
- A scope launch whose bootstrap never consumed its launch request now fails
with LinuxScopeNotStartedError, and the runtime clears the verdict it
launched under, so the next eligible spawn probes again and records the
fallback when that probe fails. A generation counter keeps launches from a
superseded probe from forcing another probe.
- After a negative verdict, the runtime records the /proc/net/unix listener on
$XDG_RUNTIME_DIR/systemd/private and probes again only when a different
listener, meaning a new manager instance, appears.
The real-socket test rejects on a listen error instead of hanging, the table
fixture comment names the accepted-connection row it models, and the remaining
linux-scope module mocks provide confirmLinuxManager. The README notes that
the socket-table read matches only listeners in the same network namespace and
costs time proportional to the table size.
systemd closes the private listening socket on shutdown without unlinking its
path, so the socket file outlives a manager stopped in place. The fast path now
reads /proc/net/unix, where the kernel lists a bound socket only while a
process holds it open, and accepts the manager only when a process listens on
$XDG_RUNTIME_DIR/systemd/private; otherwise the systemctl query decides. The
scope startup error names an unreachable manager as the cause, and the spawn
test again asserts that Windows launches never reach the fallback.
Without a per-spawn check, a user manager that stops after a positive probe,
such as after a logout without lingering, made every later scope launch fail.
Each eligible spawn now confirms the manager: its socket file at
$XDG_RUNTIME_DIR/systemd/private disappears when the manager stops, so its
presence answers with one stat; otherwise the restored systemctl --user show
query decides and the spawn falls back while it fails. A crashed or hung
manager that leaves its socket file still fails the scope launch with the
existing restart hint.
The probe always passes --collect, which systemd-run rejects before v236, so those hosts use the fallback regardless of the --expand-environment=no retry.
With the probe cached for the provider lifetime, a user manager that stops
after a positive probe makes every later scope launch fail. The startup error
now says to restart the provider to probe native containment again. The README
no longer lists older user-systemd among fallback hosts and notes the second
probe attempt for a systemd-run that rejects --expand-environment=no.
The README says the provider also keeps a failed or timed-out first probe and
names the user manager as reachable rather than live, since it is checked once.
The probe cache documents why no later spawn repeats the probe, and the spawn
test now starts two Windows commands to match its per-spawn Job recheck title.
systemd-run before v254 rejects --expand-environment=no, so the scope probe
always failed and those hosts used the weaker fallback. Its scope mode never
expands argv, so the probe retries without the option only when systemd-run
reports it as unrecognized, and scope launches reuse the probed options.
systemctl before v252 rejects --kill-whom. Use --kill-who, the older
spelling, which newer systemctl accepts as an unambiguous prefix.
Every Linux spawn() and spawnTerminal() ran a synchronous spawnSync probe
before launching the command: the full systemd-run scope probe until it first
succeeded (a failed verdict was never cached), then a systemctl manager recheck.
spawnSync blocks the host event loop until the probe exits, so hosts without a
usable user manager paid the full probe on every grep, glob, and shell command.
Keep the first verdict, positive or negative, for the provider lifetime and
drop the per-spawn manager recheck.
Delete @deepseek-ai/dsh-invariants, every package's ./invariant companion
and its tests, the invariant gates and Vitest setup, the scoped-event
resolver generator, and the INVARIANT-coded listener rethrow paths.
Update docs, READMEs, Agent Notes, and add an upgrade guide.
Review follow-ups: the Electron payload smoke allocates the way the product
does on Windows, so the packaged tree resolves the console host assets it now
depends on; the README states that dependency and its loud failure as a known
limitation; the terminal handle takes the platform this method already
resolved; and the note links the earlier tail-grace note, records why that
grace cannot cover a malformed tail, and drops the claim that the smoke
exercises the operating system host.
The README, the Ctrl-C comment, and the bare-kill comment still described
Windows' own console host as the one backing the PTY. Name the host the code
now selects, record the exit-event behaviour of both teardown paths measured on
the pool machine, state that a missing conpty asset fails loud, and move the
note to the bug-fix class next to the other Windows console fix. The terminal
case now asserts the option key is absent on POSIX rather than not true.
OutputCollector.spillAll ran openSync/writeSync inside the stream 'data'
listener with no containment. When the private per-process spill directory
had been removed by a temporary-file cleaner, the first overflowing chunk
threw ENOENT from Pipe.onStreamRead, became an uncaught exception in the
Desktop Host, and the Electron shell reported it as a fatal failure.
Spilling is best-effort by contract (spillPath is optional; a failed final
close already withholds it). Every filesystem failure while opening or
appending the spill file now discards the spill, reports once through the
owner's logger, and leaves in-memory collection untouched. Spill inputs move
into one SpillOptions object with the reporter, so tail-only collectors no
longer pass an empty directory; prepareManagedProcessBinding is the explicit
resolve step for the directory and reporter defaults, and the runtime and
SSH helper supply their plugin loggers.
Tests cover ENOENT on open (removed directory), ENOTDIR on open, and ENOSPC
on append after the file exists, asserting the tail stays byte-exact, the
spill file is withdrawn, and the reporter fires once.
Range observation waited for an active transient scope to leave `active`
before it would prove the managed range empty. systemd ends a scope only when
it observes the populated-to-empty transition, so a payload killed before the
manager placed it in the scope's cgroup leaves the unit active with an empty
cgroup forever. `waitForExit()` then polled a unit that could not change, and
`LocalSubprocessRuntime.disposeManagedProcesses()` awaited it without bound —
teardown hung for any command cancelled immediately after spawn.
Each state query now also reads the unit's process count. Once termination was
requested and the launcher has exited, a still-active unit reporting no
processes is the empty range: the launcher forked every process it will ever
fork, so nothing later joins that cgroup. The owner stops the leftover unit so
transient units cannot accumulate, and an unreported or `[not set]` count
stays unknown and keeps waiting.
The race needs a live user-systemd manager, so it stayed dormant wherever the
native scope path was unavailable and surfaced once CI routed lanes onto the
Blacksmith hosted image, whose runners accept that path. Ten repetitions of
`local.spec.ts > disposal kills still-running processes and awaits their exit`
in a Linux container with a user manager failed seven times before the change
and passed ten times after it.
Run the dsh-archive-agent-notes audit over every active Agent Note on
current master, judging each record by whether its rationale still guides
work rather than by size or age.
- Archive 453 implemented bilingual triplets (417,882 English words):
completed UI chrome, narrow adapters, closed bug fixes, implementation
walkthroughs whose package READMEs, docs pages, generators, or successor
notes now carry the useful behavior, and 51 records fully superseded by
a later active note. Keep 201 implemented notes whose ownership rules,
negative guarantees, durable or wire semantics, security rules,
reintroduction conditions, or still-tempting rejected alternatives
remain useful.
- Reject 7 proposals whose premise is gone or whose work shipped in
amended form under other records; delete 2 rejected notes that no
longer prevent a plausible mistake.
- Retarget every remaining inbound link to the archived path, and repair
active prose that named an archived record as the owner of a live fact:
parenthetical citations drop, ownership sentences redirect to the
README, docs page, or active note that states the fact, and history
citations say so. Chinese files link the English archived path because
the pairing gate treats the frozen tree as outside the bilingual corpus.
- Seal 1,359 new frozen artifacts; existing seals are unchanged and
outbound links from archived notes are neither inspected nor repaired.
- Regenerate docs/config-catalog.md after the hook-bridge comment edits
shifted two source line numbers.
Second review round (ds-review-bot v4p/v5/v6): the exit cleanup claim that
collectors unlink their spill files on dispose was wrong — completed spill
files are retained (seal() only closes, discardSpill() is the only unlink),
so the exit removal only ever applies to directories that never spilled.
Reword the JSDoc, README, and Agent Note accordingly, drop the redundant
readdirSync precheck (rmdirSync ENOTEMPTY is caught anyway), and recount the
v8-ignore window. The spawn.spec default-spill test now records the default
per-process directory it created and removes it in afterAll, so a completed
spill file is never left behind by the unit suite.
Review (ds-review-bot) found the exit-time deletion of per-process spill
roots conflicts with the documented retention decision: spill artifacts are
model-visible locators that resumed or forked sessions may still reference
(2026-07-17-local-spill-startup-cleanup), and an exception thrown from an
'exit' listener can change the process exit code.
- dsh-spill-local: revert the exit handler entirely; its default root is
already owned by the package's 30-day startup sweep.
- dsh-subprocess-local/spawn: remove the per-process spill dir at a
JavaScript-observable exit only while it is EMPTY (collectors unlink
their spill files on dispose), best-effort with a named swallow so a
Windows-held handle never changes the exit code; dirs holding spill
files keep them for external cleanup.
- Document the empty-dir exit removal in the package README and the Agent
Note, and re-record both bilingual pairs.