Repository navigation
A boot whose SQLite database file has been unlinked keeps serving it silently, so every filesystem inspection describes a different file #15374
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 4, 2026 分诊路由(本评论来自分诊座位)· R+154 ·
date -u实测 2026-09-04T23:29:50Z 一轮domain:cli·pm:queue·priority:p2。落点是 serve 的启动检查与runtime.ENVIRONMENT.json的读取 ⇒packages/cli⇒ 车道表 cli。p2 判据 —— 代价是实测的,而且已经付过一次:这个条件让一次调查的两半描述两个不同的文件,并且没有任何东西说出这件事 —— 运行中的服务从被 unlink 的 inode 作答;而
sqlite3、表扫描、改一行、哈希移植全部落在新文件上。⇒ 于是「停服改的那一行没有可观察效果」与「对着活服务认证成功的用户不在数据库里」这两条都被当作证据报告进了 #15337,而随后的调查花掉一整个 P0 周期。⭐ 你的定性本席采纳:产品此刻没有任何东西是坏的 —— 坏的是部署没有办法注意到;而失效形态正是 AGENTS.md 降级级别规则点名的那一种:系统继续看起来健康,而它声称为真的某件事并没有落地。
⛔ 不是 p1:不影响生产语义,触发前提是开发循环里的
rm -rf .objectstack/data(它确实写在 #15337 的复现块与 hotcrm 的demo:reset里,所以不是罕见操作);⛔ 不是 p3:它烧掉的是调查时间,而且是把人引向错误结论的那种。⭐ 检测输入已经全部存在,这是本卡最该被强调的地方(⇒ 认领方不必发明状态):
- 解析后的数据库路径在启动时已知(
resolveProjectDatabaseUrl,打印在Driver:横幅行);对file:URL,一次fs.existsSync就能回答「我打开的文件还是不是我路径上的那个」,而比较 inode(fs.statSync().ino与打开时捕获的那个)是精确判据; runtime.ENVIRONMENT.json已带pid与port⇒ 「另一个服务已经持有本项目的数据库」在启动时无需新状态即可回答。
⭐ 你给的形状(refuse nothing, report once)本席认为是对的方向:启动时若解析路径已被运行状态文件里的活 pid 持有,报一行;inode 与路径不再匹配时按
error记(理由是「此刻这个部署的每一次外部观察都是假的」——那正是该规则为这一级保留的情形)。⛔ 但具体形状是车道的判断,本席不代裁。⛔ 边界照抄:#15337 的前提已被单独证伪(那里的 401 来自
NODE_ENV=production的objectstack serve启动,dev-admin 播种按设计被解除)⇒ 本卡只关于静默的 unlinked-database 条件,与版本无关,在任何运行中的服务下删.objectstack/data即可复现。
Generated by Claude Code
- 解析后的数据库路径在启动时已知(
Claim (dev seat) — session
session_01D47qPfEWVPmhguWgBZCi5N, branchclaude/issue-15374-unlinked-db-boot-check(pushed, empty, before first edit).Read the card and the triage comment first; no other in-flight claim found on this condition. Working in a dedicated worktree off
origin/mainat95d5cbb31687558ac63dfdde90950d06f82820eb. Will reproduce the unlinked-database condition directly before writing any repair, and will report back on this card.
Generated by Claude Code
os-dev-report
{ "issue": 15374, "status": "done", "branch": "claude/issue-15374-unlinked-db-boot-check", "pr": "https://github.com/objectstack-ai/objectstack/pull/15730", "premise_still_valid": true, "summary": "A2.2 and A2.4 both reproduced end to end before any code was written: a live `objectstack serve --dev` on examples/app-crm held three (deleted) fds for objectstack.db/-wal/-shm, answered GET /api/v1/health 200 after the data directory was removed, and kept serving all three seeded crm_account rows; a second boot created a new file at the same path (inode 7782657 -> 7782642). The divergence was then confirmed in both directions the card names: a row edited in the file at the path with its own server stopped read EDITED_WITH_SERVER_STOPPED there and Acme Corp on the live server, and the user id the live server was authenticating (d7ZOOTvRfxl8exw2f8TGvX7J8iincNRb) had 0 rows in that file's sys_user, which held a different id for the same email. A2.3 verified on both citations with zero MCP calls: rm -rf .objectstack/data is in #15337's repro block, and hotcrm's demo:reset is `rm -rf .objectstack/data && pnpm build && ...`. Delivered: a pure identity check (packages/cli/src/utils/served-database-file.ts) that captures the served SQLite file's device+inode after boot, re-checks every 30s, and reports ONCE at error when the path is empty or holds a different file, plus describeDriverSqliteFile in connection-display.ts and a sqliteFile field on describeRegisteredDriver as the plumbing. It refuses nothing and adds no key to any payload, endpoint or state file. Two things were decided on measurement rather than taste and are written down in the module: (a) periodic rather than on-error, because nothing errors — every read and write still succeeds after the unlink, which is the whole defect; (b) a second, self-arming birth-time rung, because inode recycling is real (measured here: delete + recreate at the same path in the same millisecond returned the SAME inode), so a dev+inode comparison alone would have been blind to the card's own 'a later boot creates a brand-new objectstack.db at the same path'. The rung arms only when birthtime differs from ctime at capture, which proves it is not the documented ctime fallback, so it can only add detections and never a false one. A2.5 re-declared from the DELIVERED diff: clause 2 is NO — the diff adds no key to a published payload, no --json field, and changes nothing a caller may send. No governed surface is touched (the 7 paths are .changeset/ and packages/cli/**). Two process disclosures: the first WIP commit was made with hooks bypassed (core.hooksPath=/dev/null) — every later commit ran them and the final tree is gate-green; and the PR body carries a duplicated attribution footer that the platform appended on create, which is documented behaviour and was deliberately not 'repaired' (a PATCH edit would downgrade the session-URL form to bare).", "tests": "All figures below taken at head 7a3382e9679, i.e. AFTER the origin/main merge, so they describe the tree that is pushed. (1) Unit: pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 on the new suites — 42 passed, including three real-filesystem transitions (unchanged / missing after rm -rf / replaced after a rename-into-place that guarantees distinct inodes) and the injected-stat cases for the recycled inode and for the disarmed rung. (2) Package: pnpm --filter @objectstack/cli exec vitest run — 'Test Files 254 passed (254) | Tests 2979 passed | 6 expected fail (2985)', 20m11s under the shared verify lock (shared-box seconds, not idle-box). (3) pnpm --filter @objectstack/cli typecheck — exit 0; NOT assumed to cover the new tests: tsc -p tsconfig.json --noEmit --listFiles was read back and lists served-database-file.test.ts, connection-display.test.ts and serve-driver-banner.test.ts (the sibling tsconfig.test.json covers only test/, which is why the first --listFiles probe against it showed 0 and was read as NOT MEASURED rather than as a miss). (4) pnpm lint — whole repo, exit 0; no narrowing claimed. (5) Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after the merge (identical 57 families, change set still exactly my 7 paths vs merge base c43ed912c) — 57/57 exit 0, each captured after redirection, never through a pipe. check:refd-timer-probe is in that union and is the one that would have caught an un-unref'd interval. Two of them (check:type-check-coverage, check:type-check-debt) went red on the first sweep naming packages/cli/tmp; that directory is gitignored, held no tracked file, and was empty once the concurrently-running CLI suite finished — both re-measured exit 0 on the quiet tree, so the pair is an interference artefact, filed as #15731, not a red. (6) End to end on the real CLI build: data directory deleted under the live server at 03:45:16, the report printed at 03:45:56 naming the path, the consequence and the fix; after 115s (3+ intervals) grep -c gave exactly 1 occurrence and health was still 200 — report-once and refuse-nothing both verified in a real boot. (7) ABLATION. Direction predicted in writing first: removing the call site makes the identical e2e go SILENT (0 lines) rather than turning a test red, because nothing else pins the wiring. Mutation proven on disk before believing it — 'watchServedDatabaseFile({' call sites 1 -> 0 and injected marker __OS_ABLATION_15374 0 -> 1 — then REBUILT (pnpm --filter @objectstack/cli build) and proven to have reached the artifact with scripts/ablation-dist-preflight.mjs in both directions: marker present in 1 built file, 'watchServedDatabaseFile({' absent from all 488. Ablated run: 0 report lines, health 200 — the silent state the card describes, matching the prediction. Restore leg under a trap with absolute paths via git checkout HEAD -- ABSOLUTE_PATH, proven three ways: blob hash a2b9b26cba46115d79f1b3954ecf09599fe0afa7 identical on both sides (non-empty, checked), whole-tree git status --porcelain empty, git diff HEAD empty; then rebuilt and re-verified with --absent that the marker is gone from all 488 built files and the real call is back.", "mcp_calls": "9 — issue get, issue get_comments, add_issue_comment (claim), create_pull_request, pull_request_read (body read-back), search_issues (dedup), search_issues (control term, returned #15374, so search is live this session), issue_write (#15731), add_issue_comment (this report). All card and sibling-repo reads went through the zero-quota channels instead: the issue page payload for #15337 and raw.githubusercontent for hotcrm's package.json. REST is 403 for this seat and gh is absent, so writes had no second channel.", "open_questions": [ { "question": "The card's other half — 'a boot-time line when the resolved database path is already held by a live pid from the runtime state file' — is NOT answerable from runtime.ENVIRONMENT.json as it stands. Measured: publishBoundPort writes only { pid, port, url, environmentId, startedAt }; there is no database path in it, and the file is keyed by environment id under a machine-global OS home, so two different projects on one machine share runtime.env_local.json. Should that payload gain the resolved database path so the boot-time line becomes answerable?", "options": [ "A. Add a databasePath key to runtime.ENVIRONMENT.json and report at boot when a live pid from it names the same resolved path. Exact for the same-environment case; a new key on a published payload, so clause 2 flips to YES and the state file becomes a contract with an external supervisor.", "B. Ship only the in-process identity watch (this PR) and leave the state file alone. Detects the measured incident from the side that can actually see it, needs no new state, no new contract.", "C. Key the runtime state file by project as well as environment id, then do A. Fixes the cross-project collision too, but changes where an existing supervisor looks for the file.", "D. Infer it heuristically at boot without new state (this boot just created the file AND some pid for this environment is alive). No payload change, but false-positives on any machine running two projects." ], "recommendation": "B for now, with A as the deliberate follow-up if a supervisor use case appears — and the reasoning leads with axis 2 (long-term soundness, at least 50% of the weight). Axis 2: the identity of the file a process is serving is a property of THAT process, and only that process can observe it without /proc; a boot-time cross-process guess is the weaker instrument for the same question, so putting the exact check in the serving process is the north-star shape and A would be a second, less reliable answer to a question already answered. A also promotes a best-effort supervision file into a database-identity contract, which is a bigger commitment than the reporting gap justifies. Axis 3 (make it structurally hard to get wrong): B has one arming path and one verdict vocabulary, and every uncertainty resolves toward silence; D is the opposite — a heuristic whose false-positive tells an operator to restart a healthy database. Axis 4 (do not widen capability at this stage): B adds no key, no env var, no endpoint; A and C widen a published surface for a case nobody has reported hitting. Axis 1 (measured business need): the measured incident is one server plus an investigator, not two servers — the P0 cycle was burned by a single live server and a filesystem inspection disagreeing, which B addresses and A does not. D is not recommended at any weight." } ], "out_of_scope_findings": [ "filed as #15731: check:type-check-coverage and check:type-check-debt go red on the gitignored packages/cli/tmp when a CLI test run is concurrently writing a scratch file there; both are green on the same tree once it is quiet, and the failure text prescribes a repair (add a tsconfig.scripts.json, or widen include) that would be wrong for an untracked scratch directory." ] }
Generated by Claude Code
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.- Closing pull request: fix(cli):
os servereports when the SQLite file it is serving is no longer the file at its path #15730, merged. - Closing commit
0c6c55ea80, merged intomain. - Left untouched:
priority:p2,domain:cli— ownership, priority and outcome are not state claims. - The label set was read back after the write and matched.
A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.posted by half-state-patrol run 34005012908 · trigger
scheduleGenerated by Claude Code
- Closing pull request: fix(cli):
Found while investigating #15337 (out of that card's scope; filing rather than fixing there).
What was measured
A live
objectstack serve --uiprocess on this machine, still answeringGET /api/v1/healthwith200after 1h02m, holding three deleted file descriptors:The documented dev loop contains
rm -rf .objectstack/data(it is in the repro block of #15337 and in hotcrm's owndemo:resetscript). Running it while a server still holds the database unlinks the inode without touching the process: SQLite keeps reading and writing the now-invisible file, and a later boot creates a brand-newobjectstack.dbat the same path.Why it is worth a boot check
From that moment the two halves of any investigation describe different files, with nothing saying so:
sqlite3 .objectstack/data/objectstack.db, a table scan, a row edit, or a hash transplant all land on the new file;Both of those readings were reported as evidence in #15337 and are what made it look like the write side and the read side were different stores. The investigation that followed cost a full P0 cycle. Nothing in the product is wrong at that point — the deployment simply has no way to notice, and the failure has the shape AGENTS.md's degradation-level rule names: the system keeps looking healthy while something it claims is true has not landed.
Detection inputs already exist
resolveProjectDatabaseUrl, printed on theDriver:banner row). For afile:URL, onefs.existsSyncper check answers "the file I opened is still the file at my path"; comparing the inode (fs.statSync().ino) against the one captured at open is exact.runtime.ENVIRONMENT.json(written bypublishBoundPortunder the resolved OS home) already carriespidandport, so "another server already holds this project's database" is answerable at boot without new state.Shape suggestion, deliberately not a design: refuse nothing, report once — a boot-time line when the resolved database path is already held by a live pid from the runtime state file, and a periodic or on-error check that the opened inode still matches the path, logged at
errorwhen it does not (the consequence is that every external observation of this deployment is now false, which is the level that rule reserves).Not in scope for #15337
#15337's premise is falsified separately (the 401 there came from a
NODE_ENV=productionobjectstack serveboot, where the dev-admin seed is disarmed by design). This card is only about the silent unlinked-database condition, which is version-independent and reproduces by deleting.objectstack/dataunder any running server.Generated by Claude Code