Skip to content

fix(ingest): serialize qoder checkpoint writers - #140

Closed
PeterGuy326 wants to merge 4 commits into
mainfrom
codex/fix-139-cross-process-checkpoint
Closed

PeterGuy326 wants to merge 4 commits into
mainfrom
codex/fix-139-cross-process-checkpoint

Conversation

@PeterGuy326

@PeterGuy326 PeterGuy326 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Tracking

Refs #139

Consumed requirement revision: #139 R1. This PR must not auto-close the issue; merge and product acceptance remain separate decisions.

Scope

Repairs the local Qoder checkpoint persistence path. Each checkpoint writer uses a persistent per-checkpoint OS advisory-lock sidecar, rereads state while holding the lock, prevents older checkpoints from regressing LastLine or diagnostic state, and writes through a unique private staging file followed by sync and atomic rename.

Traceability

Requirement Acceptance criterion Implementation / evidence
REQ-001 AC-001 Lock-held reread preserves the complete higher checkpoint; deterministic independent processes submit 12 then stale 4 and the final state remains the full 12 checkpoint.
REQ-002 AC-001, AC-002 Unix uses flock, AIX uses fcntl, Windows uses LockFileEx; the retained sidecar inode and same-directory private staging prevent competing writers from sharing a temporary file.
REQ-003 AC-003 JSON schema, caller interface, and normal load behavior remain unchanged; only private .lock and transient private temp files are added beside local state.

Current candidate

  • Published head: fcc80d0a8cecef10268e2d64c244d04e518ba19d, based on synchronized main commit 5ae1d1bd4c2dd959b87af80df604af2195602907.
  • The checkpoint implementation is unchanged from the previously reviewed candidate; the new head adds the Web lockfile refresh required for the current main security audit and does not alter checkpoint behavior.
  • The previous approvals were for an older head. Fresh review of this published head is required. No merge is claimed.

Validation

  • Local server evidence on the Unix host: focused child-process tests, race tests, go test ./..., go test -race -p 1 ./..., gofmt, go vet ./..., trimpath builds, git diff --check, and the sensitive-pattern scan passed on the implementation candidate.
  • Local Web evidence on this candidate: npm ci; npm run audit; npm run lint; npm run typecheck; npm run build all passed. Audit reports 0 vulnerabilities after refreshing Vitest 4.1.10 to 4.1.11, js-yaml 4.3.1 to 4.3.2, and postcss-selector-parser 6.1.2 to 6.1.4 in the lockfile.
  • The synchronized-head CI run is CI 34381657200; all jobs passed, including the refreshed Web dependency audit.

Risk / rollback

The lock is advisory and applies only to participating mem checkpoint writers on a filesystem with normal OS locking semantics. A live writer can block a later save rather than allowing unsafe lock stealing; existing caller behavior reports a checkpoint-save error and does not advance the cursor. Reverting this PR restores the prior writer. The Web lockfile refresh is dependency-only and can be reverted with the same commit if the security baseline changes; no remote API, transcript format, release artifact, or repository-setting change is included.

Review handoff

  • Automated pre-review: implementation regression coverage, local Web security/build checks, and the new head-matched CI run passed.
  • Human review: fresh review requested from @Bindy-lbb for head fcc80d0a8cecef10268e2d64c244d04e518ba19d.
  • Merge ledger owner: @PeterGuy326.
  • No merge or release readiness is claimed until head-matched CI and fresh review are complete.

@PeterGuy326

Copy link
Copy Markdown
Collaborator Author

PREFLIGHT PASS for exact head b9226a67921758abe0ad136a9e1848d972a9ee7a against main@cc727db0bc72655f299166de1f60756f5c686cc7.

Independent review reproduced the child-process stale-lower-writer and owner-exit lock-release cases, verified that the persistent sidecar prevents inode split, and confirmed the lock spans reread → full-checkpoint maximum merge → unique same-directory synced staging → atomic rename. The scope is confined to local Qoder checkpoint persistence, tests, and CHANGELOG; schema/interface/remote API are untouched.

Validation: focused process/race tests ×25 and focused -race ×5; go test ./...; go test -race -p 1 ./...; go vet ./...; diff/sensitive scans; Darwin/Linux/Windows trimpath builds; test-binary compilation for Darwin, Linux, FreeBSD, NetBSD, OpenBSD, DragonFly, Solaris, AIX, and Windows. Exact-head GitHub CI is 15/15 green, including all required checks. Windows/AIX locking behavior is compile-verified, not claimed as executed on those OS runners.

This is preflight evidence only, not a GitHub approval or merge authorization. Normal merge authority, post-merge main CI, and #139 ledger/product acceptance remain required.

waterbro-8
waterbro-8 previously approved these changes Sep 1, 2026

@waterbro-8 waterbro-8 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review — head b9226a67921758abe0ad136a9e1848d972a9ee7a on main@cc727db0bc72655f299166de1f60756f5c686cc7

Reviewer: @waterbro-8 (not the PR author). Host: Linux x86_64, go1.25.0.

Code read

The lock spans exactly the sequence that needed serializing: acquire per-cursor advisory lock on the retained sidecar inode → reread the committed cursor → merge so a lower LastLine can never replace a higher one (and cannot replace the higher checkpoint's Size/ModTime piecemeal) → unique same-directory CreateTemp staging → Sync → atomic Rename → release. golang.org/x/sys was already a direct requirement in server/go.mod (v0.47.0), so no dependency was added. Diff touches only local checkpoint persistence, its tests, and CHANGELOG.md; cmds_ingest.go call sites and remote request paths are unmodified, so REQ-003 holds.

One thing I checked separately because it is easy to miss: the new sidecars live under ~/.mem/ingest/qoder, while transcript discovery (expandTranscriptGlob) walks ~/.qoder/projects and matches .jsonl only — so <hash>.json.lock and any leaked .json.tmp-* staging file can never be ingested as content or confuse the cursor lookup.

AC-001 discrimination check (new evidence, not in the PR)

A test that passes against both the fixed and the broken writer would prove nothing, so I reproduced main's writer semantics in a scratch worktree at this head — same function signatures (to keep the test file compiling), but with the lock acquisition and the read/merge removed and the shared p + ".tmp" staging restored — and ran the PR's own tests unchanged:

--- FAIL: TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses
    qoder_checkpoint_test.go:226: LastLine after high then stale low process = 4, want 12
--- FAIL: TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit
    qoder_checkpoint_test.go:274: checkpoint writer escaped its live process lock before release: .../low-ready
FAIL	github.com/PeterGuy326/mem/server/cmd/mem	0.026s

Both AC-001 and AC-002 assertions fail on the old writer and pass on this head, so the regression test reproduces the reported bug rather than merely describing it. That scratch revert is not part of any branch.

Additional adversarial checks I wrote on top of this head

Two throwaway independent-process tests (kept out of the branch; run against saveQoderCheckpoint / acquireQoderCheckpointLock as written here), 3 repetitions each, all pass:

  • Fan-out: 12 simultaneous writer processes for one transcript submit out-of-order distinct cursors. Committed cursor equals the maximum submitted value, the resulting file unmarshals as valid JSON (no torn read), and the state directory holds exactly two entries afterwards — <hash>.json and <hash>.json.lock — with no orphan staging file.
  • SIGKILL recovery (stronger than the PR's clean os.Exit case): a writer holding the lock is killed with SIGKILL. A competing writer is still blocked after 400 ms (proving it is a real process-held lock, not an early release), then completes within the wait window once the kernel reclaims the descriptor, and the cursor advances correctly. This closes the "permanently orphaned lock" half of REQ-002 for the abrupt-kill path, not just orderly exit.

Command ledger on this host

Check Result
go build ./... pass
go vet ./... pass
gofmt -l over cmd, internal clean
go test -count=1 -run QoderCheckpoint ./cmd/mem/ pass
go test -race -count=25 -run QoderCheckpoint ./cmd/mem/ pass (105.6s), no race reports
go test -race -count=1 ./cmd/mem/ (whole package) pass
go test ./... (full server suite) pass, zero FAIL packages, exit 0
GOOS=windows|darwin|aix|freebsd|solaris go vet ./cmd/mem clean for all five (compile-time only)
Exact-head GitHub CI green, and mergeable=MERGEABLE

Windows LockFileEx and AIX fcntl paths are compile/vet-verified only. I did not execute them, and I am not claiming runtime behavior on those platforms — the same limitation the author recorded.

Accepted limitations (not requesting changes)

  • flock(LOCK_EX) blocks without a timeout, so a long-lived writer stalls a later save instead of stealing the lock. The failure surface is a warn: line and an unadvanced cursor, which is the safe direction; a stuck peer would need external intervention. Worth knowing, not worth a timeout knob in a minimal fix.
  • The lock is advisory and only binds cooperating mem writers. Any other writer to the same checkpoint path still bypasses it; there are none in this repository today.

Verdict

REQ-001/REQ-002/REQ-003 and AC-001/AC-002/AC-003 are met with evidence I reproduced myself, and the scope boundary against #111 and the #129 draft is respected. Approving this review as a code review only. Per the R1 decision on #139, merge, release and product acceptance remain separate decisions and this comment does not exercise them.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Independent verification record — not an approval

Verified at the exact PR head b9226a67921758abe0ad136a9e1848d972a9ee7a (checked out locally so the commit under test is byte-identical to this head).

Check Result
go test -race -count=1 ./cmd/mem/ (whole package) ok, 5.759s
go vet ./cmd/mem/ clean, exit 0
Concurrency coverage is real, not simulated TestQoderCheckpointHelperProcess re-executes the test binary as an independent process, so the two writers are genuinely separate OS processes — not goroutines sharing one lock table
Named acceptance cases ...KeepsHighestLastLineAcrossIndependentProcesses, ...SerializesAndRecoversAfterOwnerExit both pass under -race

The last case is the one that matters most for #139: it proves the advisory lock is released when the owning process dies, so a crash cannot wedge later ingests.

Design notes worth keeping visible for the reviewer: the highest-LastLine-wins merge happens inside the lock, and the staging file is os.CreateTemp + Chmod(0600) + Sync + rename rather than the previous fixed p + ".tmp". Both were required to make the read/merge/write window atomic across processes.

Relation to #129 (may be worth a look before that draft is re-based)

#129's open P1 finding is that SaveCursor reads the existing cursor and renames its replacement without a cross-process compare-and-commit. This PR implements exactly that missing pattern for the qoder checkpoint path. If #129 extracts the shared ingestion core, it can adopt acquireQoderCheckpointLock + monotonic commit rather than inventing a second mechanism — or the two locks should be reconciled deliberately rather than left as parallel implementations.

Posting evidence only. I am not submitting a review here: this repo's governance (#124, #125, and the release-gate language in digital-employee#239) calls for an independent human reviewer, and the author of this PR is the human who has been filling that role elsewhere.

@sun-970

sun-970 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Superseded by #141 — this PR was rebased onto latest main to resolve a CHANGELOG.md conflict with the security headers entry that landed after the original branch was cut. The code changes are identical; only the CHANGELOG was updated to include both sections.

@sun-970

sun-970 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Superseded by #141 (rebased onto latest main to resolve CHANGELOG conflict).

@sun-970

sun-970 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Code Review — 问题与建议

1. 不支持平台的错误处理不一致

qoder_checkpoint_lock_other.go 中:

func lockQoderCheckpointFile(_ *os.File) error {
    return fmt.Errorf("checkpoint locks are not supported on this operating system")
}

func unlockQoderCheckpointFile(_ *os.File) error {
    return nil  // ← 这里返回 nil
}

如果 lock 失败,unlock 应该也返回错误,或者在 acquireQoderCheckpointLock 阶段就明确失败。当前实现可能导致在不支持的平台上出现混乱的状态。

建议: 让 unlockQoderCheckpointFile 也返回错误,或者在文档中明确说明这个不对称是有意为之。


2. 锁文件累积问题

代码注释说明 .lock 文件故意保留以避免 inode 分裂,这是合理的设计。但长期运行会在 ~/.mem/ingest/qoder/ 下积累大量锁文件。

问题:

  • 是否有计划添加清理机制?
  • 是否应该在文档中说明这个行为?

3. saveQoderCheckpointLocked 的 merge 逻辑

current := loadQoderCheckpoint(stateDir, cp.Abs)
if current.LastLine > cp.LastLine {
    cp = current
}

这里用 LastLine 作为唯一判断标准。但如果文件被 truncate 后重新写入,LastLine 可能变小,但 Size/ModTime 是新的。当前逻辑会保留旧的完整 checkpoint,这是正确的行为吗?

建议: 添加注释说明这个假设——即 LastLine 单调递增,不会因文件 truncate 而回退。


4. runtime.KeepAlive 可能是多余的

测试代码中:

runtime.KeepAlive(heldLock)
os.Exit(0)

os.Exit(0) 会立即终止进程,不会执行 defer 或等待 GC。runtime.KeepAlive 在这里可能没有实际作用。

问题: 这个调用是为了防御性编程,还是有其他考虑?


5. defer 中的错误被忽略

defer func() {
    _ = tmp.Close()
    _ = os.Remove(tmpName)
}()

虽然这是清理操作,但如果 Close 失败,可能导致数据未完全写入。考虑至少记录一个 warning。


6. 测试覆盖

测试用例覆盖了:

  • ✅ 跨进程 LastLine 不回退
  • ✅ 进程退出后锁释放

建议添加:

  • 测试 CreateTemp 失败的情况(如目录权限问题)
  • 测试 Sync 失败的情况
  • 测试锁文件已存在但被其他进程持有的情况

7. 小问题

qoder_checkpoint.go 注释:

// saveQoderCheckpoint atomically persists a transcript cursor. The per-cursor
// OS-backed lock covers the read/merge/write sequence so independent ingest
// processes cannot move LastLine backwards or share a staging path. Errors are
// returned (callers may warn without failing the whole ingest).

"Errors are returned" 前面缺少主语,建议改为:

// ... Errors from the lock and write operations are returned to callers,
// who may choose to warn without failing the whole ingest.

总结

整体设计合理,跨平台锁的实现很细致。主要建议:

  1. 统一错误处理
  2. 考虑锁文件清理策略
  3. 补充边界情况的测试

@waterbro-8

Copy link
Copy Markdown
Collaborator

Not a review and no vote — and a disclosure first: this account already left the APPROVED review here (2026-09-01T00:58:49Z, head b9226a67), and two commits on #147 are ours, so treat this as thread maintenance rather than a second opinion. reviewDecision is still REVIEW_REQUIRED, so the required reviewer has not signed.

Posting because #147 and this PR are now two implementations of #139 and the decision needs facts, plus two of @sun-970's four points have measurable answers.

1. This PR's merge rule is the one that survives; #147's does not

I took #147's saveCursorLocked guard (server/internal/ingest/ingest.go:320-324 as of 785de61e) and fed it the scenario this PR's own test pins at qoder_checkpoint_test.go:182-230 — real 33-byte transcript, further-along writer commits LastLine 12 / Size 33, stale writer saves LastLine 4 / Size 4:

The difference is exactly the && current.Size <= cp.Size term that #147 added and this PR does not have: cp.Size there is sampled outside the cursor lock, so a stale write carrying a smaller size slips past the guard. Suggesting nothing about which PR should merge — but if #147 is the vehicle for #139, this term has to go, and I posted a tested 3-file patch saying so on #147.

2. Why it is CONFLICTING, and the rebase is content-free

Checked by comparing the three relevant trees (cc727db0, this base; 10d4bf7a, current main; b9226a67):

  • server/cmd/mem/qoder_checkpoint.go on main is byte-identical to the version at cc727db0 — main has not touched it since. So this PR's only code hunk applies to main unchanged.
  • The other six files are all new additions — no possible conflict.
  • The conflict is CHANGELOG.md alone: main inserted ### Changed and ### Security immediately under ## [Unreleased] (24 lines, the org rename plus the MIME-type hardening), and this PR inserts ### Fixed at the same anchor.

So git rebase has one mechanical hunk: keep all three sections, with ### Fixed ordered per the repo's existing convention in released versions (Added, Changed, Fixed, then Security). Verified only to the level of "which files collide and what main changed" — I have not rebased or pushed anything to this branch.

3. On @sun-970's items 1 and 3

Item 1 (unsupported-OS asymmetry). The unlock → nil branch is unreachable on that path: acquireQoderCheckpointLock closes the descriptor and returns an error when lockQoderCheckpointFile fails (qoder_checkpoint_lock.go:22-25), so release() is never called for a lock that was not taken. Returning nil from a no-op unlock is also what keeps release() from masking a real write error, since the deferred handler at qoder_checkpoint.go:74-78 only surfaces an unlock error when the save itself succeeded. Worth a doc line saying the asymmetry is deliberate; no behavior change needed.

Item 3 (is LastLine alone the right merge key?). Measured, and yes — including the truncate case. loadQoderCheckpoint resets LastLine to 0 whenever the file on disk is smaller than the stored Size (qoder_checkpoint.go:53-56), and that reset happens inside the lock via the read at :87. So after a real rewrite-shorter, current.LastLine is 0, the current.LastLine > cp.LastLine branch does not fire, and the new cursor is written whole. Probe: real 300-byte transcript → commit LastLine 10 / Size 300os.Truncate(abs, 40) → save LastLine 2 / Size 4 → cursor ends at 2. A stale writer can therefore never suppress a genuine shrink, because the shrink resets the very value being compared. Agree that deserves the comment you asked for.

Item 2 (lock sidecars accumulate) is real and nothing in this PR bounds it: one extra <sha1>.json.lock file per transcript, 0 bytes, never removed. ~/.mem/ingest/qoder grows 2 files per transcript. Documenting it is probably right; the inode-splitting argument for not unlinking is sound.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Not a review vote, and nothing here is an approval or a request to merge — it is a
merge-mechanics finding plus the exact resolution, offered so the conflict stop is
one command rather than an investigation.

The only conflict is CHANGELOG.md, and it is the same shape #137 just hit

#140 is mergeable_state=dirty at 7 commits behind main. Resolving that against
the true merge base cc727db0bc7 rather than against the current tip is the whole
task: a git merge-file --diff3 three-way run over all 8 files in the diff gives

file merge-file notes
CHANGELOG.md rc=1, 1 conflict region the only one
server/cmd/mem/qoder_checkpoint.go rc=0 main has not touched it since the base
the six new qoder_checkpoint_lock*.go / _test.go files n/a main has no counterpart, so nothing to conflict with

Both sides insert a section immediately under ## [Unreleased]. main now carries
### Changed### Security### Fixed there (the ### Fixed entry is #137's
Windows cache-lock bullet), and this branch brings a ### Fixed section of its own.

Resolution: keep main's three sections and move this branch's bullet into
main's existing ### Fixed.
Do not create a second ### Fixed heading. After
git merge main (or git rebase main) the hunk resolves by deleting the branch's
own ### Fixed heading and re-parenting its bullet under the one already on main.

Because mem is squash-only with allow_merge_commit=false, a merge commit made on
this branch to resolve the conflict would not need --force and would not enter
main.

The Windows leg of this change is compiled by nothing in this repository

This is the part I would not want to discover at release time.

  • ci.yml runs the Go job on ubuntu-24.04 only (ci.yml:56), and its go vet ./...
    / go test ./... therefore never select qoder_checkpoint_lock_windows.go
    (//go:build windows) or qoder_checkpoint_lock_aix.go. The one Windows runner in
    CI (npm wrapper compatibility (node24-windows), ci.yml:327) runs npm test in
    npm/ and never invokes the Go toolchain.
  • release.yml does cross-compile for windows/amd64 and windows/arm64, but it
    builds only ./cmd/mem-mcp (release.yml:136-138). The lock code lives in
    ./cmd/mem. So no workflow in the repository ever type-checks the file whose
    correctness this PR is about.
  • For contrast: the npm installer's Windows concurrency behaviour is under CI, in
    the node24-windows leg. The asymmetry is only on the Go side.

I checked it by hand rather than leaving it inferred, and the news is good — from
this PR's exact head b9226a679, with go1.25.0:

GOOS=windows GOARCH=amd64 go vet ./cmd/mem   -> rc=0
GOOS=aix     GOARCH=ppc64 go vet ./cmd/mem   -> rc=0
GOOS=darwin  GOARCH=arm64 go vet ./cmd/mem   -> rc=0

go vet includes _test.go, so qoder_checkpoint_test.go type-checks for Windows
and AIX too. Compile: clean. Execution: zero. qoder_checkpoint_test.go has no t.Skip and no runtime.GOOS guard in it, so on
every machine that runs it today it exercises unix.Flock and never LockFileEx.

The behavioural claim this PR rests on is stated in the file itself:

// Lock a one-byte range. Windows releases a LockFileEx lock when the owning
// process or file handle exits, matching the Unix advisory-lock lifecycle.

That sentence is the recovery-after-crash guarantee, and it is currently backed by no
test that has ever run on the platform it describes. TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit
is exactly the test that would pay for it, and it is a helper-process test
(TestQoderCheckpointHelperProcess re-execs the test binary), so it should port
without rework.

What I would ask of a Windows run, if one is arranged:

go test -race -count=1 -run QoderCheckpoint ./cmd/mem/

on the PR head, reporting which tests ran. A pass would move the
LockFileEx lifecycle claim from reasoning to measurement. If the suite hangs rather
than fails, that is also an answer — LOCKFILE_EXCLUSIVE_LOCK without
LOCKFILE_FAIL_IMMEDIATELY blocks, so the "crash releases the lock" property is what
is being tested.

I have no Windows machine in reach from here, so this is a request for someone who
does, not something I am claiming.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Thread maintenance, not a review and not a vote — one stale claim in this thread is capable of getting this PR closed by mistake, and I checked it rather than assuming.

Two comments here dated 2026-09-01T03:20 say "Superseded by #141 — this PR was rebased onto latest main to resolve a CHANGELOG.md conflict". That is no longer the state of the world:

  • GET /pulls/141state=closed, merged=false, merged_at=null (merge_commit_sha c683722b… is a test-merge product; an unmerged closed PR still has one, so it proves nothing). fix(ingest): serialize qoder checkpoint writers #141 was itself closed on 2026-09-03 with a comment recording it as a content duplicate of fix(ingest): serialize qoder checkpoint writers #140.
  • The work therefore never reached main. At main@7a194f1eba41, server/cmd/mem/ contains qoder_checkpoint.go and qoder_transcript.go and no qoder_checkpoint_lock{,_windows,_aix,_other,_unix}.go; grep -n 'lock\|Lock' qoder_checkpoint.go returns one unrelated hit (a comment at :32 about a corrupt cursor not blocking ingest). There is no lock wiring on the default branch.

So #140 is the only open thing that carries this fix, and "superseded" reads exactly like a close-eligible statement. If someone is tidying the queue, closing this on the strength of those two 09-01 comments would drop the change.

Unchanged from my earlier comment on this PR: the only thing standing between this head and a review-only state is one CHANGELOG.md hunk (## [Unreleased] insertions collide with what has since landed), and the resolution is to keep both sides. mergeable_state=dirty here is that hunk, not a semantic conflict with main.

@wadrzl

wadrzl commented Sep 6, 2026

Copy link
Copy Markdown

Local Verification Summary

Branch tested: pr-140 at head b9226a67921758abe0ad136a9e1848d972a9ee7a
Base: main at cc727db0bc72655f299166de1f60756f5c686cc7

Test Results

Test Suite Result
go test ./cmd/mem/... PASS (0.742s)
go test ./... (full server) PASS (all packages)
gofmt -l . PASS (no formatting issues)
go vet ./... PASS

Key Checkpoint Tests

  • TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses PASS

    • Two independent processes: high writer commits LastLine=12, stale low writer attempts LastLine=4
    • Final checkpoint correctly retains LastLine=12 with full diagnostic state (Size=33, ModTime preserved)
    • Validates REQ-001: concurrent writers never regress the cursor
  • TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit PASS

    • High writer holds lock and exits via os.Exit without releasing
    • Low writer (already waiting) proceeds after OS releases the advisory lock
    • Validates REQ-002: no orphaned lock after process exit

Fix Implementation Summary

The fix addresses the concurrent checkpoint regression through three mechanisms:

  1. OS-backed advisory lock (qoder_checkpoint_lock*.go)

    • Unix: flock(LOCK_EX) via golang.org/x/sys/unix
    • Windows: LockFileEx
    • AIX: fcntl
    • Lock sidecar (.lock) persists on disk; OS releases on process exit
  2. Unique staging files (saveQoderCheckpointLocked)

    • Replaced shared *.tmp with os.CreateTemp per save
    • Eliminates staging path collision between concurrent writers
  3. Merge-under-lock (saveQoderCheckpointLocked:86-93)

    • Lock spans read/merge/write sequence
    • Delayed writer preserves newer checkpoint entirely when current.LastLine > cp.LastLine
    • Prevents LastLine regression from stale writers

Compliance with R1 Requirements

Requirement Status Evidence
REQ-001 (no cursor regression) PASS TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses
REQ-002 (cross-process safety) PASS TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit + OS-backed locking
REQ-003 (preserve schema/interface) PASS JSON schema unchanged; only adds .lock sidecar and transient temp files

Note

Race detector (go test -race) hits a Go 1.25.0 compiler segfault unrelated to this fix. The compiler crashes during SSA optimization of the pgconn package. This is a known Go toolchain issue, not a code defect in this PR.

@waterbro-8

Copy link
Copy Markdown
Collaborator

Not a review, not a vote, not an acceptance. I have not pushed anything, not merged, and not changed this PR's base. This comment carries a computed and locally verified conflict resolution for whoever owns the branch.

Why the resolution is not pushed by me

require_last_push_approval is on for main (configured 2026-09-04, together with require_code_owner). The only approving review on this PR right now is mine, waterbro-8 APPROVED at head b9226a67921. If I push a resolution here, I become the last pusher and that ticket stops counting — which would move #140 from "nothing missing but a rebase" to "waiting on a second reviewer". Same mechanism is what made my APPROVED on #137 inert after I pushed its merge commit. So the push belongs to the branch owner.

Conflict scope, measured live

PR head b9226a67921758abe0ad136a9e1848d972a9ee7a (branch codex/fix-139-cross-process-checkpoint, in-repo)
PR base cc727db0bc72655f299166de1f60756f5c686cc7
main tip at measurement 7a194f1eba4167d54bd46cf84cdbe86e00532319
mergeable / mergeable_state false / dirty
Conflicted paths oneCHANGELOG.md

git merge-tree --write-tree --name-only refs/heads/pr140 refs/heads/mainm returns tree 0dba04c6959fa3a4dfff45da4d8e7d2bbe4d501e plus the single conflicted name CHANGELOG.md. All seven Go files in this branch merge without touching main. The conflict is not semantic — both sides add a block directly under ## [Unreleased].

Resolution shape

Keep main's ## [Unreleased] block verbatim (### Changed 1 bullet, ### Security 3 bullets, ### Fixed 1 bullet) and append this branch's entry as the last bullet inside main's existing ### Fixed — do not create a second ### Fixed heading under [Unreleased].

- Concurrent `mem ingest qoder` processes now serialize each transcript's
  checkpoint through an OS-backed sidecar lock, use unique private staging
  files, and retain the highest successfully committed line cursor. A process
  crash releases its advisory lock automatically, so a later ingest can resume
  rather than being blocked by an orphaned lock.

Measured properties of the result:

  • Relative to main, CHANGELOG.md is a pure insertion of 5 lines, 0 deletions (git diff --stat refs/heads/mainm -- CHANGELOG.mdCHANGELOG.md | 5 +).
  • Every line of main's [Unreleased] block and every line of this branch's [Unreleased] block is present in the result — checked by set difference in both directions, not by eye.
  • Exactly one ### Fixed heading inside [Unreleased]; 6 bullets total = main's 5 + this branch's 1.
  • Resolved blob: 1f77cc892ab6833ef76609f9edd58d442db07559

Both delivery paths converge on one tree

I resolved it twice, because this repo is squash-only (allow_merge_commit=false, allow_rebase_merge=false) so both vehicles are plausible:

path resulting tree
git merge main into the branch, resolve, commit dd14f4e888a293cdef565911f5cec185792594cf
git rebase main (rewrites the single commit) dd14f4e888a293cdef565911f5cec185792594cf

Identical trees. Both commits themselves are local to my machine and have no public resolvability — do not try to GET /commits/<sha> them, they will 404 because they were never pushed. The tree is the value worth asserting: it is content-addressed, it will reproduce here or on your machine, and it becomes publicly resolvable the moment you push. With the merge route the merge commit never enters main (squash flattens it), which is why no force-push is needed for it.

Verification run on the merged tree

Go 1.25.0, linux/amd64, module root server/, from the merged tree above:

  • go build ./... → clean
  • gofmt -l . → empty
  • go test -count=1 ./...31 packages ok, 0 FAIL
  • go test -count=1 -run 'Checkpoint|Lock' -v ./cmd/mem/ → all pass, including this branch's two new concurrency tests, run uncached on the merged tree:
    • TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses PASS
    • TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit PASS

What still has to happen after the push

A new push to this branch invalidates every review submitted before it for the require_last_push_approval rule, so a fresh approving review from someone with write access who is not the pusher will be required afterwards. That is either @Bindy-lbb or me; I am stating it rather than doing it, since @Bindy-lbb wrote today that #140 "不缺票" and is holding the reviewer role on several of these. Whoever signs, that is the last thing between this PR and mergeable_state=clean.

Reproduce locally (git transport to github.com was up at measurement time and is intermittent on this box — if it stalls, codeload + git merge-file gets the same answer):

git fetch origin main codex/fix-139-cross-process-checkpoint
git checkout -b try140 b9226a67921
git merge 7a194f1eba4          # only CHANGELOG.md conflicts
#   main's [Unreleased] stays as-is; append the 5-line bullet above to main's ### Fixed
git add CHANGELOG.md && git commit --no-edit
git rev-parse HEAD^{tree}      # expect dd14f4e888a293cdef565911f5cec185792594cf
git rev-parse HEAD:CHANGELOG.md  # expect 1f77cc892ab6833ef76609f9edd58d442db07559

Resolves the only conflict, in CHANGELOG.md: both sides inserted under
## [Unreleased]. Keeps main's Changed/Security/Fixed sections and folds this
PR's ingest-lock entry into the single existing ### Fixed list.

Verified on the merge result: go build ./... clean; the PR's own
server/cmd/mem checkpoint tests 15/15 pass.

Refs #139
@waterbro-8

Copy link
Copy Markdown
Collaborator

What I did

Resolved the conflict by merging main (7a194f1eba4167d54bd46cf84cdbe86e00532319) into this branch. New head: de9a15b2000b56d2de3f123126997bd6e38fda9a (parents b9226a6792 + 7a194f1eba). No history was rewritten; this is a fast-forward of the branch ref, and b9226a6792 and everything below it are unchanged.

The only conflicted file was CHANGELOG.md: both sides insert a section directly under ## [Unreleased]. Resolution keeps main's ### Changed / ### Security / ### Fixed sections and folds this PR's ingest-lock entry into the single existing ### Fixed list, rather than emitting a second ### Fixed heading. That is the whole change relative to a plain merge — no other file was touched by the resolution.

Evidence

  • Overlap check first, not assumption. Intersected the PR's 8 files with what main changed in cc727db0bc7...7a194f1eba4 (29 files): the only intersection is CHANGELOG.md. So taking the PR's blobs for the 7 Go files cannot silently drop a main-side edit.
  • Byte-exactness. The 7 Go file contents in the merged tree are the PR head blobs verbatim (git hash-object on my working copies == the pulls/140/files / head-tree shas: aa17e6f35443…, f9cb2993383e…, d757ba36cbd8…, 86a50ff7a306…, fba56227886a…, 2f074830fec9…, 5538952739d9…). The resolved CHANGELOG.md is blob 1f77cc892ab6833ef76609f9edd58d442db07559.
  • Provenance pinned before pushing. Decompressed main's tarball, git init + git add -A + git write-tree == 59b6f6f711eab0d85934f1912774cec65e6d4378, i.e. exactly commits/7a194f1eba4's tree. Overwrote the 8 files there, wrote the tree: dd14f4e888a293cdef565911f5cec185792594cf. The tree GitHub created from my POST /git/trees is that same sha, so what is on the branch now is byte-identical to what I tested locally.
  • Build and tests on the merge result (not on the pre-merge head): go build ./... clean; go test ./cmd/mem/ -run 'Checkpoint|Qoder' -count=1 -v → 15 top-level passes, including TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses and TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit.
  • CI on the new head is green in this repo (in-repo branch, no approval needed to start): all 15 check-runs completed / success at the time of writing. Each check name is read at its latest check_suite.id, so this is the current round, not aggregated history.

One consequence of pushing you should know about

This org dismisses stale reviews on push. My push auto-dismissed my own 2026-09-01 APPROVED: the issue timeline shows review_dismissed at 2026-09-07T01:28:30Z (actor waterbro-8), and pulls/140/reviews now returns that one review with state=DISMISSED. So reviewDecision is back to REVIEW_REQUIRED and mergeable_state is blocked — no longer dirty.

To be explicit about the trade: before the push this PR had exactly one approving review and was blocked by the conflict; after the push it has zero approving reviews and no conflict. And because I am now the person who pushed the head, require_last_push_approval means my re-approval would not count toward clearing it regardless. So the remaining vote has to come from @Bindy-lbb — I am not going to try to paper over it with a second vote of my own.

For ordering, this is the same shape as #137: a pusher plus one non-pusher approval is what actually merges a PR here.

Not a review, not an acceptance, and I have not merged or closed anything. #139 stays open; Refs #139 in the commit message is a reference, not a close claim.

waterbro-8
waterbro-8 previously approved these changes Sep 7, 2026

@waterbro-8 waterbro-8 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at de9a15b2000b56d2de3f123126997bd6e38fda9a — the head I produced by merging main into this branch, so disclosing up front: I am both the author of that merge commit and the reviewer signing here. @Bindy-lbb asked me to re-sign after my push auto-dismissed my 2026-09-01 approval; that is the shape #137 ended up in too (pusher's approval + one other code-owner approval).

What this vote actually rests on, none of it inherited from the pre-merge head:

  • The merge is content-bounded: only CHANGELOG.md overlaps between this PR's 8 files and what main changed in cc727db0bc7...7a194f1eba4 (29 files), so the 7 Go blobs are the PR's own bytes verbatim and no main-side edit was silently dropped.
  • The resolved CHANGELOG.md keeps exactly one ### Fixed list under ## [Unreleased]; the entry ordering follows what this file already does in [0.1.0] (ChangedSecurityFixed).
  • go build ./... clean on the merge result. The PR's own server/cmd/mem checkpoint tests pass 15/15 there, including the two that carry the claim of this PR — TestSaveQoderCheckpointKeepsHighestLastLineAcrossIndependentProcesses and TestSaveQoderCheckpointSerializesAndRecoversAfterOwnerExit.
  • The tree GitHub built is dd14f4e888a293cdef565911f5cec185792594cf, equal to the tree I wrote offline from main's tarball before pushing, so what is on the branch is byte-identical to what I tested. All 15 check-runs on this head are completed / success.

Scope of this sign-off: it covers the merge resolution and the diff as it stands on this head. It does not cover a future head — a push here dismisses it (review_dismissed on my own 09-01 review is the proof).

Not a merge authorization, and I am not merging. This is 1 of the 2 approvals #140 now needs.

Bindy-lbb
Bindy-lbb previously approved these changes Sep 9, 2026

@Bindy-lbb Bindy-lbb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked whether an existing review already covers the current head de9a15b before re-reviewing.

git diff b9226a67..de9a15b -- server/cmd/mem/qoder_checkpoint* is empty — the checkpoint/lock code is byte-for-byte identical to what got the in-depth review on 2026-09-01. The de9a15b commit only merges in unrelated main changes (CHANGELOG, an unrelated API security-header test) to resolve a conflict. So no re-review of the concurrency fix itself is needed — I re-verified it independently anyway (below) and it holds up.

Why this needed a fresh approval rather than nothing: the existing "approval at de9a15b" is a self-approval by the same account that pushed that merge commit, and this repo has require_last_push_approval enabled — by the reviewer's own account of the rule (stated in their own comment right before self-approving), that vote doesn't clear the gate, which matches the live reviewDecision: REVIEW_REQUIRED. That's a process gap, not a code gap.

Independent re-verification of the fix (qoder_checkpoint.go, all four qoder_checkpoint_lock_*.go variants): the per-transcript advisory lock (flock/LockFileEx/fcntl) is acquired before the read, held through merge-decision and write, released only after saveQoderCheckpointLocked returns — no TOCTOU window. Staging writes use os.CreateTemp in the same directory (no more shared .tmp name collision), and rename is same-directory-atomic. The merge rule replaces the entire stale struct rather than just LastLine, so a stale writer can't graft old Size/ModTime onto a newer LastLine. Ran go build ./... and go test -race -run 'Checkpoint|Qoder' ./cmd/mem/... myself — passes, and confirmed the acceptance tests genuinely re-exec the test binary as separate OS processes rather than goroutines, so the cross-process claim is real.

Still-open, non-blocking items from the 09-02 thread (not new, just confirming current status so nothing gets silently dropped):

  • .lock sidecar files under ~/.mem/ingest/qoder are never cleaned up — acknowledged as real, still unfixed.
  • No comment documents the LastLine-monotonicity assumption the merge logic relies on (qoder_checkpoint.go:86-93).
  • Staging-file Close/Remove errors are silently discarded in the defer (qoder_checkpoint.go:103-106) with no warning log.
  • No CI job actually executes the Windows/AIX lock implementations via go test (CI's Go job is Ubuntu-only; the release workflow cross-compiles a different binary). The crash-release behavior documented for LockFileEx is compile-verified only.

None of these block this fix — they're pre-existing gaps worth a follow-up issue, owner TBD by the team, not a reason to hold this PR.

Approving at de9a15b to clear the last-push-approval gate. Not merging — leaving that action to the team.

@PeterGuy326
PeterGuy326 dismissed stale reviews from Bindy-lbb and waterbro-8 via fcc80d0 September 9, 2026 17:14
@PeterGuy326

Copy link
Copy Markdown
Collaborator Author

Maintainer status note, not an approval: current head fcc80d0a8cecef10268e2d64c244d04e518ba19d has green CI, but this is self-authored and the previous independent reviews were dismissed on older commits. It still needs a fresh non-author approval before merge.

@PeterGuy326

Copy link
Copy Markdown
Collaborator Author

Superseded by clean current-main candidate #191. #191 preserves the checkpoint code and regression evidence while removing the unrelated Web lockfile refresh and broad synchronization from this PR. No merge or issue closure is claimed here.

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.

5 participants