Skip to content

refactor(ingest): extract the shared local ingestion core - #129

Closed
waterbro-8 wants to merge 2 commits into
bytefolk:mainfrom
waterbro-8:issue/111-shared-ingestion-core
Closed

waterbro-8 wants to merge 2 commits into
bytefolk:mainfrom
waterbro-8:issue/111-shared-ingestion-core

Conversation

@waterbro-8

@waterbro-8 waterbro-8 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Extracts the local ingestion mechanics that PR #108 hardened inside the qoder transcript connector into a new internal package, server/internal/ingest, so that put --watch (#110) consumes one core instead of writing a second state layer (adjudicated on #110 R2, D-4).

Moved out of server/cmd/mem:

Concern New home
Deterministic recursive transcript walk, rooted on one canonical absolute identity ingest.CanonicalRoot, ingest.Walk + ingest.HasJSONLExtension
Per-path line cursor, atomic write through a per-save temp file plus a keep-committed-progress guard, shrink/rewrite reset ingest.Cursor, CursorPath, LoadCursor, SaveCursor, FileState
--dry-run / --limit semantics, per-file degradation, cursor persistence ingest.Run
Report vocabulary (scanned / ingested / deduped / unchanged / changed / local_gone / failed), where unchanged and local_gone are reserved names Run does not populate ingest.Report
Closed failure-code classification ingest.Code, ingest.Classify

Left in the connector: cobra flags, the Qoder JSONL parser, the /v1/memories payload shape, Idempotency-Key derivation, the HTTP upload, and all stdout/stderr text. qoder_checkpoint.go is deleted; its cursor is the core's.

The extraction itself is behaviour-preserving — no new dependency, no server change, no payload change, no flag change, no cursor format change — with two intentional exceptions the review asked for, both in mem ingest qoder's identity and reporting rather than in the moved mechanics: a root is now canonicalized before it keys anything, and failure kinds survive to the classifier instead of being flattened. Their compatibility consequences are in decision 2 below and in the changelog.

Why this is a draft

#111 is still status: needs-design, and AGENTS.md step 2 wants status:ready before a material change lands. The work was driven forward on the technical owner's request so the extraction (and the two decisions it forces) can be reviewed as code rather than as prose. It is not merge-eligible until the issue is promoted and the decisions below are ratified.

Proposed owner decisions to ratify

#111 explicitly left these open at kickoff; the implementation picks a value for each, and the choice is load-bearing, so it needs a ruling rather than a silent default.

  1. Package location — server/internal/ingest (the alternative was an internal file under server/cmd/mem). Chosen because both sinks are CLI-driven and the server has no knowledge of local paths, so the package must be CLI-side but not inside package main, which is where the "second copy drifts" problem came from. internal/ keeps it out of the public surface.
  2. Cursors key on the canonical absolute path (sha1(abs) filename, abs recorded in the body), where abs is now resolved through ingest.CanonicalRoot before the walk, the project/session split and the Idempotency-Key see it. The review established why the caller's spelling cannot be used: two working directories that each contain sessions/p.jsonl and share one ~/.mem/ingest/qoder collide on one cursor, and the second run silently ingests nothing.
    • Migration policy: re-key and accept one replay, no compatibility shim. A cursor written for a root spelled relative (or behind a symlink) is orphaned, so that store is posted once more under new keys. Keeping the old spelling as a fallback key would preserve the collision, which is the bug.
    • Blast radius: only a --root that is not already a canonical absolute path. The default ~/.qoder/projects and an absolute --root keep the keys they have today, which is what the AC-001 byte dump measures.
    • Cost: the connector shipped in feat(ingest): add mem ingest qoder for AI-agent conversation transcripts (#103) #108 on 2026-08-30 and no release contains it, so this is a pre-release correction rather than an operator-facing migration.
    • Still open for ratification: whether a duplicate replay against a shared workspace is acceptable where an operator has already ingested a relative store, or whether chore(ingest): extract the shared local ingestion core (walk, cursor, change gate, report) from the qoder connector #111 should instead require an absolute --root and refuse a relative one.
    • Path-plus-device identity is still not adopted, and the known consequence stands: the same file reached through two distinct absolute paths keeps two cursors.

Spec gap found while implementing

REQ-001 names a "stat-gate-then-hash change decision". No content hashing exists in the shipped connector, and none is invented here: the only change gate in #108's code is size-based — the cursor records the file size at write time, and a file that has since become smaller is treated as rewritten so its cursor resets. A same-size in-place edit is therefore not detected. ingest.Run's contract comment states this plainly ("The change decision is size-based, not content-hashed … adding a content gate is a decision to make, not an implementation detail of a call site") so #110 does not read the absent gate as a bug in this PR. Wiring a real content gate would change ingestion behaviour, which #111's non-goals forbid ("Do not 'improve' qoder parsing semantics while extracting").

Stated as the narrowing the review asked for: #111 is a mechanical extraction of existing behavior, and the shared change-state contract is not delivered here. The stat-then-hash observation gate is a #110 design decision with its own cost (a content hash per file per cycle), not a missing piece of this refactor. Report.Unchanged and Report.LocalGone are therefore reserved names that Run never populates — labelled "reserved" in the field comments, with Changed documented as "at least one unit of the file was accepted this run" rather than "the file changed" — so #110 cannot read the shared vocabulary as an implemented change-state contract.

Acceptance-criteria mapping

  • AC-001 — behaviour-preserving. Both parts measured, not asserted:
    • PR feat(ingest): add mem ingest qoder for AI-agent conversation transcripts (#103) #108 suite passes with import-path changes only: across the whole branch, server/cmd/mem/cmds_ingest_test.go deletes exactly four lines — the loadQoderCheckpoint(...) call sites that become ingest.LoadCursor(...) — and its other 302 added lines are the import block plus five new test functions. No assertion in a pre-existing test was edited.
    • Identical memories bodies and identical cursor bytes: a throwaway harness drove mem ingest qoder over one shared fixture tree (3 transcripts under 2 projects, fixed mtimes, one file returning HTTP 409 mid-run to exercise degradation) against a stub server, recording every request byte, every cursor byte, stdout, stderr and exit code — twice, the second run against the same state dir to capture incremental replay. Compiled at the base revision and at this head, the harness produced byte-identical dumps: SHA-256 e7e042ea081bc6dd92ad27661daf999419e6550ce898f7fa97654140141c5643, 5348 bytes each, over 9 POSTs (7 first run, 2 replay-path rerun) and 3 cursors, with no stray staging file left behind. Request bodies were captured as raw wire bytes, so payload key order is included in the comparison. The harness is scratch-only and is not part of this commit; it is deliberately not added as a permanent test because it compares two revisions, which no single checkout can do. The first version of the harness stopped the stub server before the rerun, so both binaries "matched" on a context deadline exceeded instead of on real replay behaviour; that is fixed and the numbers above are from the corrected run. The harness passes a canonical absolute --root, which is why the dump is unchanged by the identity fix — the relative-root case is covered by CLI tests instead, and its compatibility consequence is decision 2.
  • AC-002 — core-level edge-case fixtures. server/internal/ingest/ingest_test.go (14 tests) pins: a dry run writes neither a request nor a cursor; a shrunk/rewritten file resets its cursor; a 409 degrades one file while the remaining files still ingest; a corrupt cursor is treated as empty and reported as state_corrupt without blocking the run; plus cursor-path stability, the exact on-disk cursor JSON (field names, key order, 0600 mode) so an old cursor round-trips through the new code, Classify coverage of every declared code including a real failed open, and Report.Add aggregation. The cursor-identity and checkpoint-staging tests are TestWalkCanonicalizesRelativeBase, TestSaveCursorDoesNotStageInASharedSlot, TestSaveCursorKeepsCommittedProgressAndLeavesNoTempFile and TestConcurrentSaveCursorPublishesWholeCursors; on the CLI side, TestIngestQoderRelativeRootKeepsSeparateCheckpoints, TestIngestQoderRelativeRootKeepsProjectSplit, TestIngestQoderUploadErrorsStayTyped, TestIngestQoderReadFailuresClassify and TestIngestQoderMapsExitCodesAtTheBoundary.
  • AC-003 — core shape. ingest.Run takes function seams (ParseFunc, UploadFunc) and returns a Report; nothing in the package imports cobra, io.Writer, or fmt.Println, and diagnostics go through Options.Log. go.mod/go.sum are untouched — no new third-party dependency. The package doc records the contract feat(sync): put --watch one-way directory watch (minimal tier, carved from Phase 2 sync drive) #110 consumes, including the two warnings that a call site must not "optimize" away (a dry run must not persist a cursor; degradation must not advance the failed file's cursor).

Validation ledger

Exact head: 6e864e1 (audit fixes on top of 3c123cd), base 3a96ad8 (origin/main after #120; branch rebased onto it before commit). main has since moved to 731a468; this branch is not rebased onto it — see the open items below.

Check Result
gofmt -l server/ PASS, no output
go build ./... PASS, exit 0
go vet ./... PASS, no findings
go test -race -count=1 ./... PASS, whole module, exit 0, 0 failures
go test -count=1 -v ./internal/ingest/ PASS, 14/14
go test -count=1 -v ./cmd/mem/ PASS, 76/76, 0 skip, 0 fail
go test -race -count=1 ./internal/ingest/... ./cmd/mem/ PASS
AC-001 pre/post dump diff (see above) PASS, identical SHA-256
git diff --check PASS

Hosted checks at 6e864e1: none reported. GET /commits/6e864e1/check-runs returns 0 entries and no action_required run is queued for this branch; the last hosted runs for it are the three that passed at 3c123cd (PR Policy, Validate Agent memory, CI). A maintainer needs to approve the workflow run for the new head before that evidence exists; it is listed as missing rather than as passing.

Not run, and deliberately not claimed: the Docker/PostgreSQL integration jobs; any end-to-end run against a real memd or a real ~/.qoder/projects store; coverage delta measurement.

Compatibility, operations, and rollback

  • Existing ~/.mem/ingest/qoder/*.json cursors are not rewritten by this change and stay readable by the new code; verified by the on-disk-format test and by the AC-001 dump (cursor bytes match, including the degraded file's last_line: 1). This holds for any root that is already a canonical absolute path, which includes the default ~/.qoder/projects. A root spelled relatively or behind a symlink resolves to a different key, so that store re-queues from line 1 and replays once under new Idempotency-Keys — the tradeoff decision 2 asks to ratify.
  • CLI surface unchanged: same flags, same summary line, same conflict warning text, same exit codes, same Idempotency-Key derivation, so re-running after an upgrade neither duplicates nor skips memories. What can differ is the internal failure tally: read and missing-file failures now report read_denied / root_missing instead of network, which is a reporting correction, not an exit-code change.
  • No migration, no database, no API, no MCP, no Web surface touched.
  • Rollback: revert the two commits (3c123cd and 6e864e1); the connector returns to its in-package cursor and walk code with no operator action. If a relative-root store was ingested after the upgrade, its replayed memories are already in the vault and are removed with forget, not by a revert.

Automated assistance

Implementation and validation were performed with automated assistance by the submitting account, and every result above is an actual measured output rather than an expectation. The human submitter remains the author and accountable reviewer. Independent CODEOWNERS approval is still required (AGENTS.md step 6), and no merge, label change, or branch-protection bypass was performed.

Refs #111
Refs #110

`put --watch` (bytefolk#110) would otherwise re-implement the same cursor store,
state-root layout, failure classification and report vocabulary that PR
bytefolk#108 already hardened for the qoder connector, and the two copies would
drift. Move walk, cursor, change gate, `--dry-run` / `--limit` semantics,
per-file degradation and report aggregation into `server/internal/ingest`
and leave the connector as a thin call site that supplies the Qoder
parser, the memory payload and the HTTP upload.

Behaviour is preserved at the bytes level, not just by assertion:
memories request bodies, `Idempotency-Key` derivation, stdout summary,
stderr conflict warning, exit status and cursor file format and location
are identical before and after extraction on a shared fixture tree. The
PR bytefolk#108 test suite passes with import-path changes only.

Refs bytefolk#111
@PeterGuy326

Copy link
Copy Markdown
Collaborator

Draft audit at 3c123cdbbf39f3b72255b6caca6b0a4e353c3618

The exact-head 15/15 checks and broad local Go/race validation pass, but this draft is not ready for review or merge.

P1 findings

  1. The claimed absolute-path cursor identity is not implemented. A relative --root is preserved through walk and cursor hashing. Two different working directories, each with sessions/p.jsonl, sharing one state directory collide on the same cursor/source identity. After A writes one record, B with same size/line count but different content emits 0 requests and silently loses ingestion. Decide the compatibility/migration policy, canonicalize roots, preserve or migrate legacy relative cursor keys/idempotency, and add a cross-cwd same-name fixture.
  2. The shared change-state contract described by chore(ingest): extract the shared local ingestion core (walk, cursor, change gate, report) from the qoder connector #111/feat(sync): put --watch one-way directory watch (minimal tier, carved from Phase 2 sync drive) #110 is not present. The extracted core still performs only size-shrink reset; same-size edits are not detected, hash is not authoritative, Changed means “an upload happened”, and Unchanged/LocalGone are never produced. Either formally narrow chore(ingest): extract the shared local ingestion core (walk, cursor, change gate, report) from the qoder connector #111 to a mechanical extraction of existing behavior, or implement the stat-then-hash observation gate and semantic tests before claiming feat(sync): put --watch one-way directory watch (minimal tier, carved from Phase 2 sync drive) #110 reuse.
  3. The real adapter erases failure kinds before the shared core classifies them. Non-409 API errors are converted to local cliError first, so report classification turns 401/402/503 and peers into network. Preserve raw API errors through the core, map to CLI errors only at the command boundary, and add adapter-level table tests for 400/401/402/409/429/502/503/504/network/read failures.

P2

Concurrent writers can regress a cursor: a slow --limit 1 run can overwrite a completed last_line=3 cursor with last_line=1. Use locking or a monotonic compare/commit with unique temp files, or explicitly enforce a tested single-writer precondition.

Gates

Positive evidence: base/head behavior is byte-equivalent on an absolute-root replay/409 harness; CLI outputs for 400/401/402/409/503/504 are externally equivalent; full Go, race, vet, build, formatting, and diff-check pass. Those results support the refactor mechanics but do not close the contract gaps above.

The connector used --root exactly as spelled, so the walk, the project/session
split and the cursor key disagreed whenever the root was relative or reached
through a symlink. Two working directories each holding sessions/p.jsonl shared
one cursor, and the second run saw an up-to-date checkpoint and posted nothing.
Canonicalize the root once and derive every identity from it.

Checkpoint saves staged through one shared <cursor>.tmp, so a second run could
fail on the name or rewind a cursor a faster run had already committed. Each
save now gets its own staging file and never moves a cursor backwards.

Failures now reach the classifier intact: the upload adapter keeps the typed API
error and the command maps exit codes at its own boundary, and the transport
check no longer runs ahead of the local-file checks that a syscall.Errno also
satisfies, so an unreadable source reports read_denied or root_missing instead of
network. A cycle that aborts while reading records the code it died on.
@waterbro-8

Copy link
Copy Markdown
Collaborator Author

Audit response at 6e864e1

One commit on top of 3c123cd. P1#1 and P1#3 are fixed with tests, P2 is fixed with a stated limit, P1#2 is answered as a scope narrowing rather than code. Draft status kept.

P1#1 — one canonical identity per store (fixed)

  • ingest.CanonicalRoot (server/internal/ingest/ingest.go:195) resolves Abs + EvalSymlinks and keeps the absolute form when the root does not exist yet, which is what Walk's existing missing-root contract needs. Walk canonicalizes base before Stat/WalkDir, so every path it returns is canonical (ingest.go:222-225).
  • The connector canonicalizes once (server/cmd/mem/cmds_ingest.go:134), so Walk, splitTranscriptPath, the cursor key and Idempotency-Key all read the same identity. Canonicalizing only inside Walk is not enough: the walk then returns absolute paths while the base handed to the parser is still relative, and filepath.Rel fails, so the project silently degrades to the parent directory name.
  • Migration policy, chosen and documented instead of defaulted: re-key and accept one replay, no legacy-key fallback. A fallback that also reads relative keys keeps the collision, which is the defect. Recorded in the PR description (decision 2), CHANGELOG.md and docs/integrations/qoder-ingest.md. Cost is bounded to a root that is not already a canonical absolute path, and no release contains the connector yet (it landed 2026-08-30 in feat(ingest): add mem ingest qoder for AI-agent conversation transcripts (#103) #108).
  • Fixtures: TestWalkCanonicalizesRelativeBase (core), TestIngestQoderRelativeRootKeepsSeparateCheckpoints and TestIngestQoderRelativeRootKeepsProjectSplit (CLI, two same-named stores in two working directories sharing one state dir).
  • Falsification observed, not assumed: with CanonicalRoot reduced to identity, the core test fails walk returned [sessions/a.jsonl], want [/tmp/…/sessions/a.jsonl]; with only the connector call removed, path[0] = "/AgentTranscripts/sessions/recruit-s3e0a", want the project taken from the store root.

P1#3 — failures classified from real errors (fixed, and the table found a second defect)

  • uploadMemory returns the error unchanged (cmds_ingest.go:229); fromAPIError is applied at the boundary that owns the SPEC §7.1 mapping (cmds_ingest.go:174). newCliError-style wrapping is now the command's job only.
  • Tables as asked: TestIngestQoderUploadErrorsStayTyped (400/401/402/403/409/429/502/503/504 + an unreachable server, asserting both the error shape and Classify), TestIngestQoderMapsExitCodesAtTheBoundary (same statuses + 500, asserting exit codes 1/3/4/5), TestIngestQoderReadFailuresClassify (missing and unreadable source, driven through Run).
  • Second defect found by writing those tests: Classify probed net.Error before the local-file cases, and syscall.Errno implements both Timeout() and Temporary(), so the *fs.PathError from a real failed open satisfied net.Error. Every unreadable or missing transcript classified as network, and CodeReadDenied / CodeRootMissing were unreachable in practice. The existing unit test missed it because it hand-builds &os.PathError{Err: os.ErrPermission} — a sentinel, not an Errno. Observed red before the reorder: missing file: Classify = "network", want "root_missing". The net.Error branch is now removed rather than reordered-ahead-of-nothing: its only outcomes already fall through to CodeNetwork, including a deadline exceeded mid-call.
  • Run also tallied nothing when a parse error aborted the cycle, so a report from a failed read carried no failure at all. It now records Classify(err) before returning, mirroring the upload branch (ingest.go:398).

P2 — checkpoint staging and monotonicity (fixed, with a stated limit)

  • SaveCursor (ingest.go:293) stages through os.CreateTemp in the cursor directory, so each save owns its staging file, and a stored cursor that is ahead of cp for a file that has not shrunk is kept instead of overwritten. The shrink exemption uses the same Size signal LoadCursor resets on, so a truncated file can still rewind.
  • Tests: TestSaveCursorDoesNotStageInASharedSlot, TestSaveCursorKeepsCommittedProgressAndLeavesNoTempFile, TestConcurrentSaveCursorPublishesWholeCursors.
  • Against the previous staging code both new cursor tests were red, and the concurrent one failed as commit checkpoint: rename …json.tmp …json: no such file or directory — the shared name was not just a theoretical clobber, the losing save errored outright and its checkpoint was lost.
  • Limit, stated rather than papered over: the audit's exact scenario is two concurrent processes, and nothing in the package gives a test a seam between LoadCursor and SaveCursor, so no test of mine makes that interleaving deterministic. The guard is proven at SaveCursor, where the rewind would otherwise land. If the owner prefers an actual lock, this is the place for it and the compare-at-write rule can be replaced.

P1#2 — narrowed, not implemented

#111 is now described as a mechanical extraction of existing behavior and the change-state contract is explicitly not claimed for this core. Report.Unchanged and Report.LocalGone are labelled reserved (they are states an observer produces, Run never fills them), and Changed says "at least one unit of this file was accepted" instead of "the file moved forward" (ingest.go:147-157). The stat-then-hash gate would change ingestion behavior, which #111's non-goals forbid, and #111 is still needs-design with no status:ready — so implementing it here would be outside what this issue authorizes. That ruling is still the owner's.

Validation ledger, exact head 6e864e1

Check Result
gofmt -l server/ PASS, no output
go build ./... PASS, exit 0
go vet ./... PASS, no findings
go test -race -count=1 ./... PASS, whole module, exit 0, 0 failures (uncached)
go test -race -count=1 ./internal/ingest ./cmd/mem PASS — 14 core tests, 11 IngestQoder* CLI tests, all executed
git diff --check PASS
AC-001 base↔head byte dump, re-measured at this head IDENTICAL: 5348 bytes, sha256=e7e042ea081bc6dd92ad27661daf999419e6550ce898f7fa97654140141c5643 for both 3a96ad8 and this head

The dump compares exit code, stdout, stderr, every raw POST /v1/memories request (path, Idempotency-Key, body bytes), every cursor file name and byte, and a second run of the same command against the same state dir and server. Only the ephemeral stub port is normalized.

Two notes on how that dump was produced, because the first attempt was wrong and the correction is load-bearing: the second run had been executing after the stub server was stopped, so it reported context deadline exceeded for both binaries — a matching pair of failures rather than a measured replay. With the server left alive across both runs, the rerun writes 2 memories and exits 0 on both revisions, and its wire bytes now match as well. The harness also reads --root as an absolute path, so it exercises the shipped path identity, not the relative-root case that P1#1 changed.

Not run, not claimed: Docker/PostgreSQL jobs, any end-to-end run against a real memd, coverage delta.

Hosted checks at this head: none reported. GET /commits/6e864e1/check-runs returns 0, and there is no action_required run queued for issue/111-shared-ingestion-core; the latest runs for this branch are still the three that passed at 3c123cd (PR Policy, Validate Agent memory, CI, all success, 2026-08-31 03:28–03:33Z). So "rerun exact-head checks" is satisfied locally and not yet on the hosted side — the workflows need a maintainer to approve the run for the new head before that row can exist. I have not treated the absence as a pass.

Still open

  • Draft stays draft. Independent CODEOWNERS approval is required, and P1#2's ruling plus the replay-vs-require-absolute choice in decision 2 are owner calls.
  • The branch base is 3a96ad8; main is at 731a468, so strict main is ahead. I did not rebase onto it here: fix(npm): treat Windows cache-lock contention as retryable #134 edits the same Unreleased/Fixed region of CHANGELOG.md, and I would rather surface that resolution than make it silently in someone else's PR. Expect CHANGELOG.md as the only conflict.
  • Transparency about this push: my commit helper defaulted to the base repository, so the first attempt created issue/111-shared-ingestion-core in fullstack-ai-infra/mem instead of moving this fork branch. Nothing referenced it (no CI ran for that commit) and I deleted it; the content exists only as 6e864e1 here. The helper now refuses to create a missing ref and refuses a non-fast-forward move, so a wrong-repo push cannot repeat quietly.

@PeterGuy326 PeterGuy326 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.

P1 correctness hold on exact head 6e864e1e5d2e2a42bc4018c1239d015e7c76c1ce.

SaveCursor reads the existing cursor and later renames its replacement without a cross-process compare-and-commit. Two writers can both read the old state, the higher cursor can publish first, and the lower cursor can then overwrite it. The current concurrent test accepts final LastLine values 1 through 8, so it permits precisely the regression the cursor must prevent.

Please use a real cross-process serialization or atomic monotonic commit protocol, then add a deterministic interleaving test that proves the final cursor is the maximum value rather than any winner. Keep the fix separate from unrelated ingestion refactoring.

The PR is also Draft, conflicted with main, and its fork head has no current workflow runs. It needs a canonical ready requirement, a clean synchronization, maintainer-approved workflows on the new fork head, and fresh required CI before final review.

@sun-970

sun-970 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Code Review — 问题与建议

1. SaveCursor 跨进程安全性的遗留问题

PR 描述中承认了 SaveCursor 在跨进程场景下的局限性:

"nothing in the package gives a test a seam between LoadCursor and SaveCursor, so no test of mine makes that interleaving deterministic"

而 PR #140 正好通过 OS 级别的 advisory lock 解决了这个问题。

问题:

  • 是否应该先合并 fix(ingest): serialize qoder checkpoint writers #140,然后在此 PR 中采用相同的锁机制?
  • 或者在此 PR 中直接引入 acquireQoderCheckpointLock 类似的机制?
  • 当前的 monotonic compare 方案在单进程内有效,但无法防止两个独立的 mem ingest 进程同时写入同一个 cursor 文件。

2. CanonicalRoot 对不存在路径的处理

PR 描述说:

"ingest.CanonicalRoot resolves Abs + EvalSymlinks and keeps the absolute form when the root does not exist yet"

filepath.EvalSymlinks 在路径不存在时会返回错误。

问题:

  • 代码是如何处理这个情况的?是先 Abs 再检查 EvalSymlinks 的错误吗?
  • 如果 root 不存在,Walk 会返回什么?是否有测试覆盖这个场景?
  • 建议添加一个 TestCanonicalRootNonExistent 测试用例。

3. Walk 的确定性排序

旧代码有 sort.Strings(paths) 确保确定性顺序。新的 ingest.Walk 是否保持了这一点?

建议:

  • 确认 Walk 内部有排序逻辑,或者在文档中明确说明遍历顺序。
  • 如果顺序不确定,可能导致 ingestion 顺序变化,影响 cursor 的增量行为。

4. 测试中的 t.Chdir 兼容性

TestIngestQoderRelativeRootKeepsSeparateCheckpoints 等测试使用了 t.Chdir,这是 Go 1.24+ 的特性。

问题:

  • 项目的最低 Go 版本要求是什么?
  • 如果需要支持更低版本,是否需要用 os.Chdir + cleanup 替代?

5. ErrDegradeFile 的检测机制

uploadMemory 返回 fmt.Errorf("%w: %s:%d", ingest.ErrDegradeFile, abs, u.Line)

问题:

  • 核心层是用 errors.Is(err, ingest.ErrDegradeFile) 检测的吗?
  • 建议在 ingest.goRun 函数中明确注释这个检测逻辑,方便后续维护。

6. Report 的并发安全性

Report.Add 是否有并发保护?Run 是单线程执行的吗?

建议:

  • 如果 Run 是单线程的,在函数注释中明确说明。
  • 如果未来可能并行化,考虑给 Report 加锁或使用 atomic 操作。

7. Cursor 格式向后兼容性测试

PR 描述声称旧的 cursor 文件在新代码下仍然可用(对于绝对路径)。

建议:

  • 添加一个测试:手动创建一个旧格式的 cursor 文件,验证 LoadCursor 能正确读取。
  • 这可以防止未来的格式变更破坏兼容性。

8. ParseFuncskipped 返回值

type ParseFunc func(abs string, skipBefore int) ([]Unit, int, error)

skipped 表示跳过的行数(unparseable lines)。

问题:

  • 如果解析器无法准确知道跳过了多少行(比如遇到空行),应该返回什么?
  • 这个值的用途是什么?只是统计还是影响逻辑?

9. 与 PR #140 的关系

#140 修复了 qoder checkpoint 的并发写入问题,而此 PR 删除了 qoder_checkpoint.go 并用 ingest.SaveCursor 替代。

建议:


10. CHANGELOG 冲突

PR 描述提到与 main 的 CHANGELOG 有冲突。

问题:


总结

重构的方向是正确的,将 ingestion 逻辑提取为独立包有助于复用。主要建议:

  1. 明确与 fix(ingest): serialize qoder checkpoint writers #140 的关系:跨进程 cursor 安全性的问题需要解决。
  2. 补充边界测试CanonicalRoot 不存在路径、cursor 向后兼容性。
  3. 确认 Walk 排序:确保 ingestion 顺序的确定性。
  4. 解决 CHANGELOG 冲突:rebase 到最新 main

@waterbro-8

Copy link
Copy Markdown
Collaborator Author

Closing this as superseded. It is our PR and our draft, and #147 states "Supersedes #129" in its own body, so leaving this open is the thing that needs explaining — not the closing. Reopen freely; nothing here was pushed or deleted.

Where @PeterGuy326's CHANGES_REQUESTED (2026-08-31) stands, item by item

Recorded because those requests are still unmet across the whole lineage, #147 included, and they should not quietly evaporate with this draft:

  1. "Use a real cross-process serialization or atomic monotonic commit protocol." Not met here. Partially met by refactor(ingest): extract shared local ingestion core with concurrent-writer safety #147: it does add an OS-backed flock/fcntl/LockFileEx sidecar lock around read-merge-write. But its merge condition also compares a Size sampled outside the lock, so the monotonic-commit half still fails — measured: given a further-along commit of LastLine 12 / Size 33 followed by a stale save of LastLine 4 / Size 4, refactor(ingest): extract shared local ingestion core with concurrent-writer safety #147's guard lands on 4/4 while fix(ingest): serialize qoder checkpoint writers #140's lands on 12/33. fix(ingest): serialize qoder checkpoint writers #140 (b9226a67) satisfies this item as written.
  2. "Then add a deterministic interleaving test that proves the final cursor is the maximum value rather than any winner." Not met here, and still not met by refactor(ingest): extract shared local ingestion core with concurrent-writer safety #147: TestConcurrentSaveCursorPublishesWholeCursors accepts LastLine 1 through 8, which is the same "any winner" acceptance your review named. I also checked whether the fix is cosmetic: tightening that assertion to require exactly 8 passes against refactor(ingest): extract shared local ingestion core with concurrent-writer safety #147's untouched head (5 runs, -race), because all eight writers share one Size. The test that actually bites needs differing Size values — which is the shape of fix(ingest): serialize qoder checkpoint writers #140's helper-process test.
  3. "Keep the fix separate from unrelated ingestion refactoring." Not met by any open PR. refactor(ingest): extract the shared local ingestion core #129 bundled it, and refactor(ingest): extract shared local ingestion core with concurrent-writer safety #147 bundles it too — the lock is its third commit, inside the chore(ingest): extract the shared local ingestion core (walk, cursor, change gate, report) from the qoder connector #111 extraction. This is the item most likely to be lost in the supersession, so I'm writing it out.
  4. "Draft, conflicted with main, fork head has no current workflow runs, needs a canonical ready requirement." All true of refactor(ingest): extract the shared local ingestion core #129, and the last two are equally true of refactor(ingest): extract shared local ingestion core with concurrent-writer safety #147: it is a fork PR from sun-970 (not an org member, not a repo collaborator) whose three check suites are all completed / action_required with an empty statusCheckRollup, so no gate has ever built it; and chore(ingest): extract the shared local ingestion core (walk, cursor, change gate, report) from the qoder connector #111's record is still status: needs-design. The only thing in this lineage with a real green CI is fix(ingest): serialize qoder checkpoint writers #140.

What I did in response, and what I deliberately did not

Links: #147 findings issuecomment-5503554595 and tested guard patch · #140 comparison and conflict cause · #139 candidate table · #111 decision request

@waterbro-8 waterbro-8 closed this Sep 2, 2026
waterbro-8 added a commit that referenced this pull request Sep 18, 2026
Refs #111

## Requirement and scope

Re-lands #147 onto current `main` as an organization branch. #129/#147
were closed under the 2026-09-03 fork-workflow decision, not as a
judgment that the extraction was wrong. Blocker PR #108 is already
merged.

Preserves qoder behaviour: same memories payload shape, same
`Idempotency-Key` derivation for a canonical absolute root, same stdout
summary, same cursor file format/location. Adds the OS-backed cursor
lock from the #147 follow-up so concurrent writers do not share a `.tmp`
name.

## Changes

- New `server/internal/ingest` package: walk, per-path cursor (atomic
rename, shrink-reset), `--dry-run`/`--limit`, closed failure codes,
report aggregation.
- `mem ingest qoder` is a thin connector (parser + HTTP upload).
- OS advisory lock around cursor load/save (`cursor_lock_*.go`).
- No `fsnotify`, no `--watch` (#110 stays a successor).

## Validation ledger

| ID | Criterion | Command | Status |
| --- | --- | --- | --- |
| V1 | Qoder tests with import-path changes | `go test ./cmd/mem -run
Ingest` | NOT VERIFIED locally — host Go 1.22, module requires 1.25 |
| V2 | Core fixtures: dry-run, shrink-reset, 409 degrade, corrupt cursor
| `go test ./internal/ingest` | NOT VERIFIED locally — same toolchain
gap |
| V3 | `git diff --check` | local | PASS |
| V4 | No cobra/stdout in the core package | source review of
`server/internal/ingest` | PASS |

Independent review still required. No merge or issue close.

Original extraction: @waterbro-8. Cursor lock follow-up: @sun-970 /
liyuanyang. Canonical-path identity follow-up: 勒布朗-詹姆斯.

---------

Co-authored-by: waterbro-8 <waterbro-8@users.noreply.github.com>
Co-authored-by: liyuanyang <liyuanyang@users.noreply.github.com>
Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
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.

3 participants