Review round on #758.
bash.run() only promises to resolve for nonzero exits, timeouts, and aborts,
and bash.resolve() can reject on policy grounds, so either could escape the
serial agent/step listener and abort the model turn — contradicting the
plugin's documented failed-query no-op contract. Contain both and log a
warning instead; the location is optional context.
The Agent Note claimed an unchanged location suppresses the query. It does
not: only the interval floor is checked before the query, while change
suppression compares state the query returned. Corrected in both languages
and re-recorded the i18n pairs.
The headless-agent test fixtures and the e2e test that depended on them
are out of scope for this PR. Unit tests in tmux-context.spec.ts cover
the plugin behavior.
The group table legitimately gained one row for the new settings group;
the row itself is already condensed to the minimum. The intended raise
missed the merge commit because a pipeline swallowed the failing edit's
exit status.
Conflicts were the parallel-edit set: packages/README group table (kept
master's session-title wording, re-added the settings row condensed),
doc-budgets AGENTS.md ceiling (took master's 1750), and regenerated
graph/catalog docs plus re-recorded translation pairs. packages/README.md
ceiling 845 -> 850: the table legitimately gained one row for the new
settings group and the row is already minimal.
The headless-agent test fixtures were removed; move the driver, cordis.yml,
and mocks into packages/context/tmux-context/tests/fixtures/ so the e2e test
is self-contained.
Confirmed and fixed, each with a regression test that failed first:
- Concurrent writes to different namespaces lost whole sections on disk
(each persist rendered the full document from a stale text): the local
provider serializes render->write->rename->text-commit on one internal
persist chain shared by every namespace queue.
- One throwing settings/updated listener starved the rest (cordis emit
stops at the first throw): commit fans out per listener via
events.dispatch, contains individual failures, and rethrows the first
INVARIANT-coded error only after every listener ran.
- Write queues ignored fiber/service lifecycle: the base init now
registers a teardown that refuses new writes and drains queued chains;
queued tasks re-verify service liveness and namespace ownership before
running and again before committing, so a registrant disposed
mid-flight is never notified and a disposed service never commits.
- Async watcher invocations could interleave (a slow stale call applied
last): each watcher carries a serialized invocation chain — one call
at a time, in commit order; JSDoc/doc pages state the async timing.
- update/replace borrowed the caller's object until the queued task ran:
inputs are structured-clone snapshotted at call time; non-cloneable
plain objects reject with a typed error.
- Composition guard now proves the documented fallback: the consumer
uses the optional scoped-inject shape and boots both with the settings
entry (hot publish) and without it (entry-config resolution, no scope).
- core-data-structures index: settings.md row added to the sub-page
table in core.md/core.zh.md.
Both packages hold per-file 100% coverage across repeated runs.
Confirmed and fixed, each with a regression test that failed first:
- Concurrent update() lost patches (merge over one stale snapshot):
per-namespace serialized write queues; a failed write cannot poison
the queue for later writers.
- Fixed-name .tmp write followed planted symlinks and kept stale modes:
random-suffix sibling, exclusive-create (wx), 0600, cleanup on
failure, then rename.
- A throwing settings/updated listener escaped commit and permanently
wedged the provider reload chain (rejected refreshTask): commit now
contains listener failures (INVARIANT-coded errors still propagate),
async watcher rejections are adopted and contained
(watch callbacks are officially void | Promise<void>), and the
provider chains refreshes on a settled tail with an error log.
- No way to remove a user override: scope/service replace(section)
sets the user section wholesale; replace({}) re-inherits base and
schema defaults.
- The three-primitive provider contract did not hold (base never
called load()): the base Service.init loads and publishes once;
settings-local delegates via yield* super[Service.init]().
- Dispose did not quiesce: teardown flags closed, closes the watcher,
then awaits queued/in-flight reloads; closed is re-checked across
await points.
- Invariant now checks the authoritative relation with the seam's own
deepEqualJson: emitted next must equal settings.get(ns), and
next/prev must differ structurally (cosmokit dependency dropped).
- New docs/core-data-structures/settings.{md,zh.md} with type-equiv
blocks + manifest entries; catalog types moved from exemptions to
LINK_MAP; website page registered.
Both packages stay at per-file 100% coverage.
Two-package capability family mirroring session-persistence/:
- dsh-settings: abstract Settings service — namespace registry with
caller-fiber effect registrations, layered resolution (schema defaults
< composition base < user document), schemastery validation,
per-namespace deep-equal commit detection, and the settings/updated
event. Boot/registration validation fails loud; provider publishes
keep last-good per namespace.
- dsh-settings-local: settings.yaml/.json provider — resolveSpec
defaulting to $DSH_HOME/settings.yaml, chokidar hot reload,
content-equality self-write suppression, atomic 0600 tmp+rename
writes, comment-preserving YAML namespace patching.
Consumers register inside ctx.inject(['settings'], …), so every
composition works unchanged without a mounted provider. Real Loader +
Include composition test proves cordis.yml boot and external-edit hot
propagation; HMR disposal test proves registry cleanup. Both packages
hold per-file 100% coverage.
Doc budgets rise 1705→1710 (AGENTS.md) and 835→845 (packages/README.md):
one structural line per file for the new package group.
Agent Note: .agents/notes/implemented/architecture/2026-07-28-user-settings-seam.md