Skip to content

fix: close the remaining 22 findings from tonight's full-codebase review ledger - #203

Merged
kevintseng merged 12 commits into
mainfrom
fix/review-r2-hooks-and-cli-friction
Aug 24, 2026
Merged

fix: close the remaining 22 findings from tonight's full-codebase review ledger#203
kevintseng merged 12 commits into
mainfrom
fix/review-r2-hooks-and-cli-friction

Conversation

@kevintseng

Copy link
Copy Markdown
Contributor

Summary

Continuation of tonight's full-codebase review (the ledger that produced
R1-R19 in v4.7.0). Works through the M-01..M-23 "minor/friction" items and
the R7-a gap the ledger left half-fixed, verified against a real, freshly
installed v4.7.1 (not just source review) — several findings (M-02/M-03/
M-05/M-06/M-18) were confirmed by actually running the released CLI in a
throwaway HOME before being fixed here.

5 commits, in landing order:

  1. 3288cc6a — R7-a completion (3 hooks still inherited the 30s busy_timeout
    openHookDb's earlier fix capped only for writers) + M-01 (status never
    opened the database) + M-14 (config set autoCapture yes silently stored
    false)
  2. aa56a587 — M-08/M-09/M-10 (doctor rows a real install should never have
    seen) + M-16 (demo tour could wire a real memory into its graph) + M-19
    (candidate window had no ORDER BY) + M-21 (stale docstring) + M-22 (three
    backfills' read-then-lock race)
  3. ef786453 — M-11 (install-hooks marker written silently) + M-12 (MCP
    namespace field missing its enum) + M-13 (.strip() silently discarded a
    mistyped config key) + M-17 (MCP forget could not report failure)
  4. 145fc2d6 — M-07 (a typo'd path was told it was a real, uncommitted file)
  5. 076f7427 — M-05 (whitespace-only observation stored) + M-06 (zero-hit
    couldn't say whether semantic search ran) + M-18 (import --merge append
    duplicated without bound; overwrite and a fresh create printed the
    identical line)

Not in this PR, checked and genuinely not defects:

  • M-04 (--force-named memory) — memesh recall -- --force already works;
    standard Commander -- behavior, confirmed empirically.
  • R20-e/R20-h/R20-k — already resolved by earlier work this same session
    (predates this review), confirmed against current tests.
  • M-23 — already fixed ("DONE earlier tonight" per the ledger itself).

Still open, deliberately deferred (flagged to the user as scope
decisions rather than fixed unilaterally): M-02 (no partial-word FTS
matching), M-03 (emoji queries return nothing), M-20 (two metadata
writers disagree on handling unparseable values). M-02/M-03 touch FTS5
tokenization and need more careful design than a same-session fix; M-20 is
a product judgment call, not a clear-cut bug.

Test plan

  • npm run lint — 0
  • npm run typecheck — 0
  • npm run build — 6/6 smoke tests
  • node scripts/check-doc-claims.mjs — every documented claim matches
  • node scripts/audit/verification-audit.mjs — every hit triaged
  • node scripts/run-tests-isolated.mjs — 185 files / 2557 tests passed
  • Every fix in this PR was break-tested individually (revert → confirm
    red → restore → confirm green) — evidence in each commit's
    [Verified-By: break-tests ...] footer
  • CI green on this PR (pending)

…onfig setters that lied about what they stored

**R7-a was only half fixed.** `openHookDb`'s writers got a 2s busy_timeout
cap in an earlier commit, but guard-check.js, session-start.js and
pre-edit-recall.js each need a `readOnly` handle — which `openHookDb`
cannot express — so all three open `MemeshDatabase` directly and inherited
the 30s cap meant for long-lived writers. Their own hooks.json budgets are
5s and 10s, so a contended lock still ended in the harness killing the
hook. All three now apply the same busy_timeout pragma.

pre-edit-recall.js has a second defect on top: it runs a guard query and
then, on the same connection, a recall query. `loadActiveGuards` swallows
the guard query's own failure by design (a guard-matching problem must
never crash this hook), so a lock still held after the busy_timeout wait
came back as "no guards matched" rather than as a visible error — and the
recall query right after it is NOT swallowed, paying the same wait again
before the hook finally gives up. Measured: ~4.4s against a 5s budget,
leaving almost nothing for the git subprocess calls and query work still
to run. A single probe before either pass now discovers a contended
connection once and gives up once.

**`status` never opened the database (M-01).** Every line it prints —
capabilities, install channel, the update check — comes from somewhere
other than the graph, so a corrupt or unreadable database file was
invisible to it: `status` printed a healthy-looking report while `doctor`
diagnosed the same file as broken. It now opens through the same
`withDatabase` every other command already uses.

**Two `config set` booleans silently stored the opposite of what was
typed (M-14).** The coercion only recognises `'true'`/`'1'` as true;
`yes`, `on`, `True` all became `false` — and the success line echoed the
raw value the user typed, not what was actually stored, so `Set
autoCapture = yes` looked like it worked. Both keys now reject a spelling
the coercion cannot read, matching every other enum-like key in this
file.

Verification:
- lint 0, typecheck 0, build 0, check-doc-claims 0, verification-audit 0
  (one new baseline entry: an exitCode `.toBe(0)` in the new config tests
  matched C1's emptiness heuristic; not an emptiness assertion, triaged)
- new: hook-time-budgets +3 read-only cases (per-case threshold tight
  enough to separate a single busy_timeout wait from pre-edit-recall.js's
  former double wait — 8s would have passed either way); error-envelopes
  +3; bad-input-and-broken-db +1
- regression: tests/hooks 343 passed; pre-edit-recall/guard-check/
  session-start(-unwritable) 51 passed; doctor-honest-pass 7 passed
- break-tests, each restored and rebuilt:
    revert guard-check.js's pragma            -> spawnSync ETIMEDOUT at 25s
    revert pre-edit-recall.js's probe          -> 4.4s exceeds the 4s bound
    remove the autoCapture/transcriptMining
      validators                               -> 2 of 3 new tests fail
    remove `status`'s withDatabase probe       -> 1 of 9 fails
…uld have seen, a latent recall window, and a demo tour that could annex someone's memory

**`memesh doctor` warned about its own translated docs on every real
install (M-08).** `README locale parity` treated README.md's mere presence
as "this install ships docs, so missing locale READMEs are a problem" —
but npm always includes README.md regardless of `files`, while the locale
READMEs are dev-only and never shipped. Every real install saw a WARN
naming translation files it was never supposed to have. Now: zero locale
READMEs present is read the same as README.md itself being absent —
packaged, skip silently — and only genuine drift (at least one locale
present, another missing or stale) still warns.

**Install ID named a path nobody's install actually uses (M-09).** The row
hardcoded `~/.memesh/install.json` while every sibling path in this file
resolves through `memeshDir()`, which a `MEMESH_DIR` override changes.
Now resolved the same way.

**A fresh install failed the one check it hadn't had time to pass yet
(M-10).** "No update check yet" is expected for an install a few minutes
old — the same 24h grace period the hook-wiring check already gives new
hooks. `isFreshInstall()` reads the install record's own `created_at`
(ISO-8601 — deliberately not `hoursSince`, which parses SQLite's
different timestamp shape and would silently read every fresh install as
"unknown age").

**A project's own candidate window had no ORDER BY (M-19).** Latent — the
largest real project measured is 177, under the 400-row cap — but above
it, `SELECT DISTINCT`'s dedup returns ascending by id: the OLDEST 400,
not the newest, with no way for ranking to ever see what fell outside
that window. The sibling "recent across all projects" query already had
the fix; this one didn't.

**A demo entity's own error-classifier comment claimed the opposite of
what it does (M-21).** Docstring-only: it described "database disk image
is malformed" as one of the messages a benign FTS delete mismatch
produces, immediately above a classifier that (correctly) refuses to
treat that message as benign. Corrected to match the code.

**Three backfills read their work list before the lock that acts on it
(M-22).** All three ran `SELECT ... FROM entities` outside `db.transaction()`,
then wrote inside it. A concurrent process — a real scenario here, since
CLI/MCP/HTTP all open the same file — could insert a row in the gap; that
row would never appear in the captured list, and the marker set at the
end of a successful pass records the backfill as permanently done anyway.
`runOnceMigration` exists for exactly this: read-and-commit under one
lock. All three now read their work list, and re-check the marker, inside
the transaction they act in.

**The demo tour could wire a real memory into its graph (M-16).** DEMO_
DATA's names (`auth-decision`, `db-choice`) are plausible names a real
memory could carry. `seedDemo` already refused to touch or duplicate a
colliding row — but `createRelation` resolves both endpoints by name
alone, with no notion of who created them, so a colliding real entity
still got a demo edge the moment any OTHER demo entity in the same run
needed inserting. Relations are now only wired between names confirmed
to actually be part of the demo tour (metadata.demo = true), whether
seeded this run or a prior one.

[Verified-By: npm run lint] exit=0
[Verified-By: npm run typecheck] exit=0
[Verified-By: npm run build] "All 6 smoke tests passed", exit=0
[Verified-By: node scripts/check-doc-claims.mjs] "Every documented claim matches the code.", exit=0
[Verified-By: node scripts/audit/verification-audit.mjs] "Every hit is triaged; every detector saw a non-empty candidate set.", exit=0 (5 baseline entries re-keyed: doctor.ts x4, demo.ts x1, each verified byte-identical before re-keying)
[Verified-By: node scripts/run-tests-isolated.mjs] "Test Files 185 passed (185) / Tests 2540 passed (2540)", exit=0
[Verified-By: npx vitest run tests/core/doctor.test.ts tests/core/briefing.test.ts tests/core/demo.test.ts] all passed (96 + 6 + 8 = 110 tests), exit=0
[Verified-By: break-tests, each restored and rebuilt after confirming red]:
    remove the anyLocalePresent guard         -> 1 of 96 doctor tests fails
    hardcode ~/.memesh in the Install ID row  -> 1 of 96 doctor tests fails
    neutralise isFreshInstall()               -> 1 of 96 doctor tests fails
    drop ORDER BY from the project query      -> 1 of 6 briefing tests fails
    drop the demoNames filter on relations    -> 1 of 8 demo tests fails
[Verified-By: M-22 note] three backfills have no dedicated new test — the defect is only observable under real cross-process write timing, which would need flaky multi-process machinery disproportionate to the fix; correctness rests on the same transactional-atomicity guarantee runOnceMigration already relies on, and the unchanged regression suite (185/185, 2540/2540 above) confirms no behavioural regression for the normal single-writer case.
… that could not fail, and two schemas that let a mistake through unremarked

**`install-hooks` wrote a second file and said nothing (M-11).** The
command reports the settings.json path and its backup path; the marker
`installHooks()` writes at the same time (~/.memesh/install-hooks.json —
what `doctor` reads to confirm hooks are wired) was never mentioned.
Reported now, gated on the same condition the write itself uses
(`added > 0 || skipped > 0`, not dry-run) — not on the backup path,
which is null on a fresh install with nothing pre-existing to back up.

**MCP export/import's namespace field didn't publish the constraint the
runtime enforces (M-12).** `remember`/`recall` declared `enum:
['personal', 'team', 'global']`; export/import only mentioned the three
values in the description's prose. A client had no machine-readable way
to know an arbitrary string would be rejected until it tried.

**`POST /v1/config` reported success for a mistyped key it silently
discarded (M-13).** `ConfigBody.strip()` dropped any unrecognized key
before validation ran, so `{sesionLimit: 77}` changed nothing and still
answered `200 {success: true}`. Every current dashboard caller posts a
small, purpose-built object (`{llm}`, `{autoUpdate: next}`) — never a
superset read back from GET — so nothing legitimate relied on an unknown
key being tolerated. `.strict()` now answers the same 400
`validation.bad-body` every other malformed POST on this route already
does.

**MCP `forget` could not report failure (M-17).** `ok(forget(...))`
wrapped every outcome in `isError: false` regardless of what happened —
a typo'd observation, or a name that doesn't exist, came back
indistinguishable from an actual removal to a caller checking `isError`
without parsing the JSON body. The CLI's `forget` command has always
exited 1 for exactly this. MCP now reports the same two cases (`archived
=== false`, `observation_removed === false`) as `isError: true`, with the
same messages the CLI already uses.

R20-e/R20-h/R20-k (from the same ledger) were checked against the current
tree and are already resolved by earlier work this session — R20-e's dead
fields (`toolPreferences`/`avgSessionMinutes`) no longer exist anywhere
in the codebase; R20-h's "no test observes a relation surviving import"
is now covered by two tests added alongside the R3-b fix (an existing-
target relation confirmed via SQL JOIN, and a target-appears-later
relation); R20-k's unconditional-initializer gap has its own dedicated
test file (`citation-compliance-numerator.test.ts`) from tonight's
citation-contract work, predating this review. No action needed.

[Verified-By: npm run lint] exit=0
[Verified-By: npm run typecheck] exit=0
[Verified-By: npm run build] "All 6 smoke tests passed", exit=0
[Verified-By: node scripts/check-doc-claims.mjs] "Every documented claim matches the code.", exit=0
[Verified-By: node scripts/audit/verification-audit.mjs] "Every hit is triaged; every detector saw a non-empty candidate set.", exit=0 (no new hits — none of these touched a C-class pattern)
[Verified-By: node scripts/run-tests-isolated.mjs] "Test Files 185 passed (185) / Tests 2544 passed (2544)", exit=0
[Verified-By: node scripts/run-tests-isolated.mjs tests/core/install-hooks.test.ts tests/core/schema-export.test.ts tests/tools.test.ts tests/transports/http.test.ts tests/transports/forget-selector-safety.test.ts]: all passed (17 + 14 + 43 + 65 + 9 = 148 tests), exit=0
[Verified-By: break-tests, each restored and rebuilt after confirming red]:
    remove the Marker console.log in install-hooks     -> 1 of 17 fails
    drop the enum from export/import's namespace field -> 1 of 14 fails
    ConfigBody back to .strip()                        -> 1 of 78 fails
    forget handler back to bare ok(forget(...))         -> 2 of 43 fail
resolveFileCommits() ran `git ls-files --error-unmatch` to check whether
the argument was tracked — but that command only checks git's index,
never the filesystem, so it fails identically for "this file exists but
isn't committed" and "no such file". Both landed on `file_not_tracked`,
whose message ("File is not tracked by git") reads as confirmation that
a real file exists and just needs a commit — sending a caller who
mistyped a path looking in the wrong place.

A new `file_not_found` abstention is checked first (`existsSync`, before
the git call), with its own message ("No such file.") and its own exit
code: 1, matching the caller-mistake convention `pin`/`forget` already
use for "the thing named does not exist" — every other `why` abstention
is about a real file `why` cannot fully explain, so exit 0 for those is
unchanged.

[Verified-By: npm run lint] exit=0
[Verified-By: npm run typecheck] exit=0
[Verified-By: npm run build] "All 6 smoke tests passed", exit=0
[Verified-By: node scripts/check-doc-claims.mjs] "Every documented claim matches the code.", exit=0
[Verified-By: node scripts/audit/verification-audit.mjs] "Every hit is triaged; every detector saw a non-empty candidate set.", exit=0 (5 baseline entries re-keyed in why.ts/cli.ts, each verified byte-identical before re-keying)
[Verified-By: node scripts/run-tests-isolated.mjs] "Test Files 185 passed (185) / Tests 2546 passed (2546)", exit=0
[Verified-By: node scripts/run-tests-isolated.mjs tests/core/why.test.ts tests/cli/why.test.ts] "Test Files 2 passed (2) / Tests 20 passed (20)", exit=0
[Verified-By: break-test, restored and rebuilt after confirming red]: remove the existsSync check -> 2 of 20 why tests fail (both new ones)
… say why, and an import that grew without bound (M-05/M-06/M-18)

**`--obs "   "` stored a memory with nothing in it (M-05).** Dogfooded on
the real v4.7.1 release: the CLI's `remember` calls `remember()` directly,
never through Zod, so nothing rejected a whitespace-only observation —
`"observations": ["   "]` landed in the graph. Fixed at both points:
`RememberSchema` gets a per-element `.refine()` (protects MCP/HTTP), and
the CLI gets its own check mirroring `forget`'s existing `nonEmpty`
pattern (protects the path that bypasses the schema). `ExportResultSchema`
is deliberately NOT touched — it is the one schema this file documents as
tolerant by design, restoring a bundle that may predate this validation.

**A zero-hit couldn't say whether semantic search even ran (M-06).**
"No results found." was identical whether the install had no embedder
configured (there might be something related this pass could not see) or
a fully working one searched everything and still found nothing (this
really searched everything) — two very different confidence levels with
one indistinguishable line. The plain-text path now reads
`retrieval.mode`/`degraded` (already computed, already in the `--json`
envelope) the same way the sibling semantic-only-results message already
does.

**`import --merge append` duplicated observations without bound (M-18).**
Dogfooded: re-running the same import twice duplicated every observation
a second time, with no cap on further re-runs — the ordinary shape of
restoring the same backup twice, or two bundles sharing an entity.
`createEntity` has no dedupe of its own (correct for `remember`, where
re-asserting a fact may be deliberate) — import now filters each entity's
incoming observations against what it already has before calling it.

**`import --merge overwrite` printed the identical line for a destructive
replace and a harmless first-time create (M-18).** Both incremented the
same `imported` counter. A new `overwritten` field (subset of `imported`)
tracks entities that already existed and had their data repladed —
carried through the CLI's own message, the type, and the documented
response shape.

[Verified-By: npm run lint] exit=0
[Verified-By: npm run typecheck] exit=0
[Verified-By: npm run build] "All 6 smoke tests passed", exit=0
[Verified-By: node scripts/check-doc-claims.mjs] "Every documented claim matches the code.", exit=0 (ImportResult's new `overwritten` field kept in sync with its documented response shape)
[Verified-By: node scripts/audit/verification-audit.mjs] "Every hit is triaged; every detector saw a non-empty candidate set.", exit=0 (7 baseline entries re-keyed, 2 new SAFE-GUARDED entries added — both verified against the sibling validator/type that makes the guarded case real, not hidden)
[Verified-By: node scripts/run-tests-isolated.mjs] "Test Files 185 passed (185) / Tests 2557 passed (2557)", exit=0
[Verified-By: break-tests, each restored and rebuilt after confirming red]:
    remove the CLI --obs check + the Zod refine (both)  -> 3 of 53 fail
    revert the zero-hit retrieval-mode message           -> 1 of 5 fails
    remove the observation dedupe + the overwritten count (both) -> 4 of 53 fail
…I runners

guard-check.js and pre-edit-recall.js both failed PR #203's CI on macOS
(Node 22 and Node 24) against the 4000ms flat ceiling this test asserted
per hook — guard-check.js measured 4516ms, pre-edit-recall.js 4378ms, on
a script that does exactly one busy_timeout wait either way. All six
Ubuntu legs passed; the ceiling was sized from local timing (~2.2s single
wait, ~4.4s double) that never accounted for macOS GitHub Actions
process-spawn overhead, which alone ran ~2.4-2.5s on top of the 2s
busy_timeout wait.

Replaced the flat ceiling with a same-script self-difference: each case
now runs the hook once unlocked (the process-spawn floor) and once against
an exclusive lock, and asserts the DIFFERENCE stays under 1.5x
HOOK_BUSY_TIMEOUT_MS. Both spawns pay the runner's spawn noise about
equally, so subtracting cancels it — the assertion is on the lock wait
itself, not on wall-clock plus whatever the runner happened to be doing.

Also fixed a real bug in the new design before it shipped: the first
draft ran the floor and contended spawns with the same file_path for
pre-edit-recall.js, and that hook throttles repeat recalls of one file
per session (session-recalled-files.json, keyed on memeshDir — which the
two spawns deliberately share). The floor run's throttle write silently
made the contended run skip its recall query, collapsing it to a single
query regardless of which code was under test — a false negative that
would have passed unfixed code. Caught by the break-test below, not by
inspection. Fixed by giving each spawn its own file_path via a
`makeInput(tag)` function instead of a fixed input object.

[Verified-By: node scripts/run-tests-isolated.mjs tests/hooks/hook-time-budgets.test.ts] Test Files 1 passed (1), Tests 6 passed (6)
[Verified-By: break-test] reverted pre-edit-recall.js's probe fix -> 'pre-edit-recall.js' case failed: "paid 4202ms above its own 64ms unlocked floor (contended run took 4266ms)" (expected < 3000ms); restored -> green again
[Verified-By: break-test] reverted guard-check.js's busy_timeout pragma -> 'guard-check.js' case failed with spawnSync ETIMEDOUT at 25051ms (inherited the 30s default); restored -> green again
[Verified-By: npm run typecheck] exit 0
[Verified-By: node scripts/run-tests-isolated.mjs] Test Files 185 passed (185), Tests 2557 passed (2557), no Errors line
[Verified-By: npm run verify:release] exit 0, all 8 verification-audit detectors new=0, check-doc-claims all pass
Commit 3e8c9d0 replaced a flat 4000ms ceiling with a same-script
floor-subtracted one (contended minus unlocked), capped at
1.5x HOOK_BUSY_TIMEOUT_MS (3000ms) — and that failed too, on the SAME
macOS CI legs, for all three scripts at once: guard-check.js 3013ms,
session-start.js 3169ms, pre-edit-recall.js 3092ms. All three currently
carry their correct fix; nothing regressed between the two pushes. What
the second failure actually shows: a contended busy_timeout wait costs
more than its nominal 2000ms even after subtracting a script's own
process-spawn floor — apparently ~50% more under real lock contention on
a loaded macOS runner (retry granularity, WAL/journal state the unlocked
floor run never touches) — and that premium is itself runner-dependent,
so a second guessed multiplier was just a different flat ceiling with
extra steps.

The three same-run measurements clustered within ~5% of each other
(3013/3169/3092), which is the actual finding: whatever "one wait" costs
right now, on this runner, under this load, different scripts pay
almost exactly the same premium for it. So stop guessing that number
and read it off a live reference instead. guard-check.js can only
structurally ever pay one wait (one query, no swallow-and-continue), so
it is measured fresh in the same test run and used as the "what does 1x
cost right now" baseline; pre-edit-recall.js's own paid-wait is compared
against THAT measurement (< 1.6x), never against a constant written down
in this file. guard-check.js's and session-start.js's own cases keep a
generous flat 15000ms ceiling — the regression they guard against
(pragma never applied, inherits the 30s meant for long-lived writers) is
off by an order of magnitude either way, so it never needed fine
calibration in the first place.

[Verified-By: node scripts/run-tests-isolated.mjs tests/hooks/hook-time-budgets.test.ts] Test Files 1 passed (1), Tests 6 passed (6); repeated 3 more times back to back, all green (4564ms/4568ms/4559ms for the pre-edit-recall.js case)
[Verified-By: break-test] reverted pre-edit-recall.js's probe fix -> failed: "pre-edit-recall.js paid 4291ms vs guard-check.js's own 2182ms single-wait reference ... expected 4291 to be less than 3491.2"; restored -> green again
[Verified-By: break-test] reverted guard-check.js's busy_timeout pragma -> BOTH guard-check.js's own case AND the pre-edit-recall.js case (which uses it as a reference) failed with spawnSync ETIMEDOUT at 25s, correctly cascading; restored -> green again
[Verified-By: npm run typecheck] exit 0
[Verified-By: node scripts/run-tests-isolated.mjs] Test Files 185 passed (185), Tests 2557 passed (2557), no Errors line
[Verified-By: npm run verify:release] exit 0, all 8 verification-audit detectors new=0, check-doc-claims all pass
… one

The previous fix widened this file's rmSync retry window from 5/100ms to
10/200ms on the theory that Windows was lagging on releasing a handle after
the child process exited. It failed again, identically, on the next CI run —
which disconfirms the theory rather than under-tuning it: a 2s window that
does not help means the holder is not lagging, it is still running.

Reading session-start.js settles it. Every runHook() reaches
runPostBannerUpdateTasks() -> spawnFreshUpdateCheck(), which spawns a
detached, unref'd `node dist/transports/cli/cli.js status` against the test's
HOME to refresh the update-check cache. That spawn is deliberately
fire-and-forget so session start never blocks on a slow npm lookup, and
nothing waits for it. On a loaded Windows runner it can still be mid-flight
during afterEach, holding a handle under <home>/.memesh. No retry budget
fixes a process that has not finished.

The one failing case being the only test that calls runHook() twice matches
this exactly, and matches nothing about a timing distribution.

This test asserts that the SessionStart hook writes the citation contract
into the installed scope. It does not assert that Windows can rmdir a temp
directory, so a cleanup failure should not fail it — a leaked temp dir on a
CI runner is harmless. ENOTEMPTY/EBUSY/EPERM are tolerated; every other error
still throws. The stale comment asserting the widened window was sufficient
is replaced with the mechanism.

Local: node scripts/run-tests-isolated.mjs tests/hooks/citation-rule-self-heal.test.ts
       exit=0, Test Files 1 passed (1), Tests 6 passed (6)
       npm run typecheck exit=0
…s-only

The previous commit read this as a Windows handle-release lag and tolerated it
in one file. Half right. The same run then failed the Release Verification Gate
on ubuntu, in a different file — tests/hooks/session-start-unwritable.test.ts,
ENOTEMPTY on rmdir '/tmp/memesh-unwritable-yQYt8W/.memesh'. Same error, same
.memesh subdirectory, no Windows involved.

Mechanism, observed rather than reasoned about. Ran the hook against a scratch
HOME and watched it:

  - the hook process exits
  - `pgrep -f "cli.js status"` still shows a live child (PID 37163)
  - ~1s later `update-check.4.7.1.json` appears in <home>/.memesh

That child is spawnFreshUpdateCheck (scripts/hooks/session-start.js:393):
detached, unref'd, deliberately fire-and-forget so session start never blocks
on a slow npm lookup. So cleanup is not waiting on a lagging handle, it is
racing a process that is still writing: rmSync enumerates .memesh, deletes what
it saw, calls rmdir, and the child has created a new file in between. That is
why widening the retry window from 5/100ms to 10/200ms changed nothing — no
retry budget outlasts a writer that has not finished.

Exactly two test files spawn the hook against a HOME they then delete
(release-scripts-safety.test.ts only writes a file *named* session-start.js; it
never runs it). Both now share one helper instead of carrying two copies of the
same reasoning — a duplicated explanation is the defect class this repository
has been bitten by before.

Neither test asserts anything about cleanup, and a leaked temp directory on a
CI runner is harmless, so the honest cleanup is to let the race lose quietly.
ENOTEMPTY/EBUSY/EPERM are absorbed; every other code still throws, and
temp-home.test.ts pins that narrowness — an unqualified `catch {}` would hide a
genuinely broken cleanup, which is worse than the flake it fixes.

No user impact: ~/.memesh is never deleted out from under a real session.

Local evidence:
  node scripts/run-tests-isolated.mjs           exit=0
    Test Files  186 passed (186)
         Tests  2563 passed (2563)
  npm run typecheck                             exit=0
  break-test — widened the catch to swallow everything:
    exit=1, FAIL tests/helpers/temp-home.test.ts > removeTempHome >
    rethrows anything else, so a genuinely broken cleanup is still visible
    (1 failed | 5 passed); file restored and re-verified byte-identical
…wning hook

Windows Node 24 failed again on PR #203, in a third file and with a third
error code:

  EPERM, Permission denied:
  C:\Users\RUNNER~1\...\Temp\memesh-readonly-budget-1x3nOq
    tests/hooks/hook-time-budgets.test.ts:160  withTempDb's finally

Same cause, different surface. That test spawns session-start.js against a
temp DB via MEMESH_DB_PATH, and the detached update-check child inherits the
env — so it holds the database file open. On Windows an open file cannot be
unlinked, which is EPERM, where the earlier failures were the child *creating*
a file mid-cleanup, which is ENOTEMPTY. One race, two symptoms.

My earlier scan for exposed files was too narrow: it filtered on tests that
set `HOME:`, and this one isolates with `MEMESH_DB_PATH:` instead. Re-scanned
on the real criterion — a test that executes a hook which spawns a detached
child, and then deletes a temp directory. Exactly two hooks spawn detached
children (`grep -c "detached: true"`): session-start.js and session-summary.js.
Ten test files run one of them and then rmSync a temp dir. Three had already
failed in CI; the other seven are the same trap waiting on runner load, and
finding them one full 13-leg re-run at a time is not a plan.

All eleven cleanup sites were byte-identical
(`maxRetries: 5, retryDelay: 100`), so this is one mechanical substitution,
not eleven judgements.

Renamed removeTempHome -> removeTempDir, temp-home.ts -> temp-dir.ts. Half
these directories are not HOMEs — hook-time-budgets' is a scratch database —
and a helper whose name misdescribes half its callers is the kind of small
lie this repository has paid for before.

Evidence:
  node scripts/run-tests-isolated.mjs   exit=0
    Test Files  186 passed (186)
         Tests  2563 passed (2563)
  npm run typecheck                     exit=0
  tests/helpers/temp-dir.test.ts        exit=0, 6 passed
    (the narrowness guard: ENOTEMPTY/EBUSY/EPERM absorbed, EACCES rethrown —
     break-tested in the previous commit, mutant killed)
…evert it

Checking my own sweep. The criterion was "runs a hook that spawns a detached
child, then deletes a temp dir", and I applied it by grepping for files that
reference session-start.js / session-summary.js and execute *something* via
node. install-hooks.test.ts matches that grep but not the criterion: what it
actually executes is `cli.js install-hooks`, and the CLI's only detached spawn
is in `feedback` (opening a browser), not on this path.

So there is no live writer here, and `removeTempDir` would have been a claim
this file makes about itself that is not true — plus an error-swallowing
cleanup in a file with nothing to swallow, which could hide a real failure
later. Reverted to a plain rmSync.

The other seven were re-checked the same way and all genuinely resolve to
scripts/hooks/session-start.js or session-summary.js.

  node scripts/run-tests-isolated.mjs tests/core/install-hooks.test.ts
    exit=0, Test Files 1 passed (1), Tests 17 passed (17)
  npm run typecheck  exit=0
@kevintseng
kevintseng merged commit d94242a into main Aug 24, 2026
13 checks passed
@kevintseng
kevintseng deleted the fix/review-r2-hooks-and-cli-friction branch August 24, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant