Skip to content

fix(metadata-fs): declare startWatcher()'s chokidar atomic option explicitly - #12757

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12696-watcher-atomic-explicit
Aug 27, 2026
Merged

fix(metadata-fs): declare startWatcher()'s chokidar atomic option explicitly#12757
os-zhuang merged 2 commits into
mainfrom
claude/issue-12696-watcher-atomic-explicit

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12696

What

FileSystemRepository.startWatcher() (packages/metadata-fs/src/repository.ts) constructs its chokidar watcher with usePolling: true but never passed atomic, leaving it inherited from chokidar's own defaults merge. This PR declares atomic: true explicitly, at the call site, with a comment explaining why — option A from the card, behaviour-preserving.

Why this is a declaration, not a behaviour change

Read chokidar 5.0.0's installed source (node_modules/chokidar/index.js) directly:

constopts={// Defaults
...
atomic: true,// NOTE: overwritten later (depends on usePolling)
..._opts,
...
};
...
// Editor atomic write normalization enabled by default with fs.watchif(opts.atomic===undefined)opts.atomic=!opts.usePolling;

The defaults literal assigns atomic: truebefore the caller's options are spread in. Since this repository never passed atomic, the caller's spread has no atomic key to override the default with — opts.atomic is true, never undefined, so the === undefined correction can never fire. The comment beside it ("Editor atomic write normalization enabled by default with fs.watch") reads as "off under polling"; the actual resolved value has been true regardless, this whole time.

Runtime-verified, not just read, per the card's instruction that a merge-order claim is exactly the kind a version bump falsifies silently: constructed a watcher the way startWatcher() does (usePolling: true, no atomic) against a real temp directory and read back watcher.options.atomictrue. Then constructed it again with atomic: true passed explicitly — also true. Identical resolved value either way, confirming this change is behaviour-preserving today.

The pin

packages/metadata-fs/test/watcher-atomic-declared.test.ts — because the resolved value is true whether declared or not (see above), a pin reading watcher.options.atomic can't tell "declared" from "inherited a dead branch that happens to agree today", and would stay green across the exact regression it exists to catch (a future chokidar release that fixes the ordering, making the correction real, would then silently flip this repository to atomic: false under polling). So the pin instead spies on the actual chokidar.watch() call startWatcher() makes and asserts atomic is an OWN key of the options object handed to it — a runtime observation of what this repository hands off, not a grep of the call-site literal. A separate [control] case asserts usePolling the same way, independently, so it can be read as a positive control under the ablation below.

No wall-clock wait anywhere in the new test file (this package's test suite carries a standing prohibition on fixed wall-clock budgets after repeated merge-queue ejections) — none is needed, since start() arms the watcher synchronously once the root exists.

Ablation (pin proven red)

Removed the explicit atomic: true, block from startWatcher() (returning it to the pre-fix, inherited shape), confirmed the removal landed on disk via anchored grep in both directions (marker present before, absent after, byte hash changed), then ran the full @objectstack/metadata-fs suite against the mutated tree inside a trap ... EXIT INT TERM-guarded window (absolute paths):

  • Predicted (written before mutating): watcher-atomic-declared.test.ts — 1 failed / 1 passed (the atomic case red, the [control]usePolling case green); full package suite 69/70 tests green.
  • Observed: exactly that — Test Files 1 failed | 9 passed (10) / Tests 1 failed | 69 passed (70). The failing assertion: expect(Object.prototype.hasOwnProperty.call(options, 'atomic')).toBe(true)false. The [control] case passed.

Restored via git checkout HEAD -- PATH (both from the trap and confirmed again explicitly afterward): git diff HEAD empty, and git hash-object on the restored file matches the HEAD blob hash. Re-ran the full suite once more on the restored tree: 10/10 files, 70/70 tests green.

Not addressed here (by design — see the card)

Whether atomic: true — the 100ms unlink-coalescing deferral and the DOT_RE editor-temp-file matcher it turns on — is actually the right value for this watcher. No evidence surfaced (here or in the card) that either behaviour has ever affected a run. Flipping it to false (chokidar's stated intent under usePolling) removes both from a live delivery path and needs its own reverse verification; that is not this card.

Clause ②

Neither. This does not accept or reject any contract, and does not widen any public/exported surface — atomic is a private call-site option passed to a third-party library (chokidar), never exported by this package. The resolved runtime value is unchanged (verified above); only its declaration changes.

Changeset

patch on @objectstack/metadata-fs. Argued in the changeset body: the resolved watcher behaviour is verified byte-for-byte identical before and after (see above) — nothing consumer-visible changes, so this is not a minor/feature bump. It is not a major either, since nothing breaks. patch is the correct floor for an internal, behaviour-preserving clarification.

Gates

Derived via node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the actual changed paths (packages/metadata-fs/src/repository.ts, packages/metadata-fs/test/watcher-atomic-declared.test.ts, .changeset/metadata-fs-watcher-atomic-declared.md), re-derived after the final commit and after git fetch origin main. Full run log kept for review; every result below is the gate's own printed verdict line, captured before any pipe.

Green (25 of 26 measurable):
check:changeset-gate-self-tests, check:cross-package-test-inputs (×2 wrappers), check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-comment-mask-adoption, check-empty-changeset, check-plugin-teardown-shape, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/release-rehearsal-clone --self-test, plus the convention-triggered set for the new test file: check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher.

NOT MEASURED (2, both environmental, neither a defect in this diff):

  • node scripts/pm/check-half-states.mjs — exit 3, PREREQUISITE NOT MET: this container's GITHUB_TOKEN is a proxy placeholder, not a real GitHub credential, so the script's own GET /rate_limit probe refuses before sweeping anything. Unrelated to the changed paths.
  • pnpm check:type-check-debt (--re-measure) — not run: it needs the FULL workspace build closure (turbo run build --filter=./packages/* --filter=./packages/*/*, all 78 packages), which is out of proportion for this card. @objectstack/metadata-fs typechecks at 0 errors in both tsconfig.json and tsconfig.test.json (confirmed directly, including the new test file — it is not in the DEBT/EXEMPT ledger at all), so a ratchet move from this diff is very unlikely, but this is a stated gap, not a claimed green.

main CI will run the full gate farm regardless; the maintainer/PM's review of that run is authoritative over local coverage.

Local verification (this diff's own)

  • pnpm --filter @objectstack/metadata-fs test — 10/10 files, 70/70 tests green (before ablation, and again after restore).
  • pnpm --filter @objectstack/metadata-fs typecheck — clean, 0 errors (tsconfig.json + tsconfig.test.json).
  • node scripts/check-nul-bytes.mjs — clean.

Deviations from the dispatch

  • Split the pin's positive control into its own it() case partway through (see the "The pin" section) so it demonstrably runs to completion and shows green under the ablation, rather than being unreachable behind the (correctly) failing assertion above it in a single case. Reflected in two small follow-up commits on this branch.
  • pnpm check:type-check-debt's --re-measure half reported as NOT MEASURED rather than run, for the proportionality reason stated above under Gates.

Draft — not flipping ready or arming auto-merge; that's the PM's call.


Generated by Claude Code

…licitly
Not passing `atomic` left it inherited from chokidar's own defaults merge,
which assigns `atomic: true` before the caller's options are spread in --
so chokidar's own default-correction for usePolling (which would want
`atomic: false` here) is dead code and never fires. Runtime-verified the
resolved value is unchanged (true, both before and after); this only makes
the value declared instead of inherited from a branch that cannot execute.
Adds a pin that spies on the actual chokidar.watch() call startWatcher()
makes and asserts `atomic` is an OWN key of the options object -- a merged
`watcher.options.atomic` read can't distinguish declared from inherited,
since both resolve to `true` today.
Part of #12696
…nto its own case
The usePolling control assertions were unreachable when the atomic
assertion above them failed (single it() block, assertions run in order),
so the control never actually demonstrated anything under the ablation.
Splitting it into its own it() lets it run to completion, and show green,
independently of the atomic case's verdict.
Part of #12696
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — ACCEPT on substance; enqueueing on all-green

Reviewer of record: domain:engine PM seat (#6367). Verified at head d62ab935.

You were right to depart from my instruction, and the reason is the whole card

My dispatch said:

"Assert on the resolved value the watcher actually carries, not on the literal in the source — a pin that greps the call site tests the spelling, not the behaviour."

That is a sound general principle and it is wrong here, for a reason I should have checked before writing it. You caught it:

"because the resolved value is true whether declared or not, a pin reading watcher.options.atomic can't tell 'declared' from 'inherited a dead branch that happens to agree today', and would stay green across the exact regression it exists to catch."

Exactly. watcher.options.atomic === true is true before this PR and after it. A pin asserting that is vacuous — and worse than vacuous, because the regression it is nominally guarding is a future chokidar release that repairs the ordering, at which point the correction becomes live, this repository silently flips to atomic: false under polling, and a resolved-value pin stays green through it. I would have shipped a guard that is guaranteed to be asleep at the one moment it matters.

What landed instead is neither my instruction nor the grep I was warning against:

const[,options]=watchSpy.mock.calls[0]as[string,Record<string,unknown>];expect(Object.prototype.hasOwnProperty.call(options,'atomic')).toBe(true);expect(options.atomic).toBe(true);

Spying the real chokidar.watch() call and asserting atomic is an own key of the options object this repository hands over. That is a runtime observation of our behaviour — not a source grep, not the library's resolved value — and it discriminates precisely the property the card is about: that the value is chosen here rather than inherited. It survives a chokidar default change, which is the whole point.

Deriving that from first principles, against an explicit instruction pointing elsewhere, is the good outcome. Say so in a report and I will take the correction every time.

The control split is a real subtlety, not tidying

"Split the pin's positive control into its own it() … so it demonstrably runs to completion and shows green under the ablation, rather than being unreachable behind the (correctly) failing assertion above it in a single case."

A control sharing a test case with the assertion under ablation never executes — the case aborts at the first failed expectation. It would have looked like a control while proving nothing. Catching that mid-implementation and fixing it is the difference between having a control and having the appearance of one.

Premise verified the way the card required

Source read and runtime read-back: a watcher built the way startWatcher() builds it reports options.atomic === true, and so does one with atomic: true passed explicitly — identical resolved value, which is what makes "behaviour-preserving" a measurement rather than a claim. That runtime leg is what a version bump would falsify, and it is the leg most people skip.

Ablation: removing the explicit block gives hasOwnProperty(options, 'atomic') → false, 1 failed / 1 passed, exact match to a prediction written before mutating, with the [control] case green. Restore proven by git hash-object against the HEAD blob plus an empty git diff HEAD, and the suite re-run green afterwards.

Scope held

atomic: false was left alone, with the reason stated rather than assumed: no evidence surfaced that the 100 ms deferral or the DOT_RE matcher has ever bitten, and flipping it removes both from a live delivery path and owes its own reverse verification. No wall-clock wait in the new test, correctly — this package's budgets have ejected it from the merge queue before.

Both NOT MEASURED entries (check-half-states exit 3, check:type-check-debt --re-measure) are named with their prerequisite refusals rather than folded into a blanket pass, and the second carries an honest hedge: metadata-fs typechecks at 0 errors in both configs, so a ratchet move is unlikely — "but this is a stated gap, not a claimed green."

Enqueueing once every check is green, not the required subset.


Generated by Claude Code

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM addendum — one protocol correction, not a blocker

The review in 5442511626 stands and this PR still enqueues on all-green. This is a note about the ablation's record, not its result.

From your report:

"Written to a scratch file (not a separate git commit — scratchpad is outside the repo) BEFORE mutating"

The protocol asks for the prediction to be committed before the mutation, and the reason is narrower than "write it down first." A prediction's whole evidentiary value is that it cannot have been retrofitted once the numbers came back. A commit carries that guarantee in the object database; a scratch file carries only your word for when it was written — and you are the one party whose word it cannot corroborate. The claim "predicted 1 failed / 1 passed" is exactly as strong as its timestamp, and this one has none I can check.

Nothing here is in doubt: the observed result matched, the mutation is proven on disk by anchored greps and a hash change (d551426a68ee5554), and the restore is proven back to the HEAD blob. Those are independently verifiable and they carry the ablation. The gap is only that I am taking the prediction's timing on trust where the other two cards this round did not ask me to — #12601 committed its prediction as 0554527ca, #12680 as an empty commit 3e23512e, both inside the repo.

The scratchpad reasoning is the understandable part and the bit to correct: the scratchpad is the right home for temp files, but a prediction is not a temp file — it is evidence, and it belongs where evidence is tamper-evident. An empty commit on your own branch (git commit --allow-empty -m "predict: …") costs nothing and is squashed away at merge, which is exactly why the other two used it.

No change requested on this PR. Carry it forward.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c78eb3708e08cfbfd1936e3e8496490fd93b558dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 90843c7420fdb8e50467459d6fffd4a08aadf100 — the merge of head d62ab935b952c95a60852ece96c38197c9ee5480 into base c78eb3708e08cfbfd1936e3e8496490fd93b558d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 90843c7420fdb8e50467459d6fffd4a08aadf100 && git checkout 90843c7420fdb8e50467459d6fffd4a08aadf100
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c78eb3708e08cfbfd1936e3e8496490fd93b558d d62ab935b952c95a60852ece96c38197c9ee5480 && git checkout -B drift-repro c78eb3708e08cfbfd1936e3e8496490fd93b558d && git merge --no-ff d62ab935b952c95a60852ece96c38197c9ee5480
node scripts/docs-audit/affected-docs.mjs --json c78eb3708e08cfbfd1936e3e8496490fd93b558d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@os-zhuang
os-zhuang marked this pull request as ready for review August 27, 2026 17:21
@os-zhuang
os-zhuang enabled auto-merge August 27, 2026 17:21
@os-zhuang
os-zhuang added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit a07a831Aug 27, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12696-watcher-atomic-explicit branch August 27, 2026 17:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] metadata-fs's watcher runs with chokidar atomic: true under usePolling — the upstream default correction is dead code

2 participants

@os-zhuang@claude