Repository navigation
feat: one seam for every .procoder/ read and write (#117 phase 0) - #249
Merged
Merged
Conversation
Phase 0 of #117. The daemon cannot be built on top of what is there now: there is no locking anywhere in internal/, every .procoder/ write is a plain os.WriteFile that truncates first, and the only repository key procoder has is a filesystem path that means different things on different machines. Those are the daemon's first three bugs, and they are bugs today — a lost dispatch record is just rarer when every hook is its own process. The spec settles the design: one typed package behind every .procoder/ read and write, per-file O_EXCL locks with stale detection, atomic writes, and an identity ladder of config, origin, first remote, path. Zero dependencies throughout, which is why the lock is a lockfile and not flock. Team mode is not here and not designed here; it is parked in #248 so this can ship without it. The plan fixes every literal so no task is left to taste, and the seven tasks each carry their test code and the failure they expect first. Also resolves the conflict left in ask/decisions.md by an earlier stash pop. The upstream side of the hunk was empty, so keeping the block loses nothing. docs: none - internal design records under .procoder/, no user-facing surface
First task of the state seam (#117 phase 0). There is no locking anywhere in internal/ today — the only mutex in the tree is in a test — and dispatch.json, claims.json and the ask ledger are all read-modify-write. Two writers lose one of the two updates, silently. That is rare only because every hook is its own short-lived process; the daemon makes it ordinary. It is a lockfile rather than flock because go.mod has no require block and a portable flock/LockFileEx pair costs golang.org/x/sys. procoder is not spending its first dependency on this. Three properties, each with a test that fails without it: - sorted acquisition order, always, whatever order the caller asked for, so two callers wanting the same two files cannot deadlock; - stale detection that treats "cannot be proven live" as dead — a lock that will not parse, or one dated in the future, is broken rather than waited on, because otherwise a killed process wedges a file until somebody deletes it by hand; - a bounded wait, because a hook that blocked would take the session with it. A failed multi-lock releases what it already took, so giving up does not leave a file locked against everybody else for thirty seconds. The broken locks come back from Lock rather than a package-level accessor: concurrent callers would race on shared state, and this package exists precisely because that concurrency is real. Mutation-checked: removing the sort deadlocks TestLockOrderIsSortedPaths, dropping the future-timestamp branch fails TestUnreadableLockIsStale, dropping O_EXCL fails TestLockIsExclusive, and skipping the release on a partial acquisition fails TestPartialAcquisitionReleasesWhatItTook. docs: none - internal package, no user-facing surface
The mutation results belong in the record, not just in a terminal that scrolls away: each of the four says which edit makes which test fail, so a later reader can check the claim rather than take it. Also records why the plan changed mid-task. It had specified a package-level Broken() accessor; concurrent Lock callers would race on it, which is the failure this package exists to prevent. The plan was corrected and re-checked before the code was written, not after. docs: none - task record under .procoder/, no user-facing surface
Second task of the state seam (#117 phase 0). Every .procoder/ write in the tree is an os.WriteFile, which truncates before it writes. A process killed in that window leaves a truncated claims.json, and the next reader reports a corrupt ledger for a crash that had nothing to do with it. WriteFile now stages the bytes in a temp file, flushes them, and renames over the target. A reader sees the whole old file or the whole new one, and a failure leaves the old one exactly as it was. The temp file goes in the destination directory rather than os.TempDir because rename is only atomic within a filesystem, and /tmp is often a different one. The prefix is fixed rather than random so a sweep can recognise litter left by a process that died mid-write, and so a person reading git status after a crash can tell what they are looking at. forceRenameFailure exists for one test and nothing else. There is no portable way to make os.Rename fail on demand, and an atomicity claim nothing can check is not worth making. Mutation-checked: emulating the old truncate-first behaviour with a os.WriteFile at the top of WriteFile fails both TestAtomicWriteLeavesOriginalOnRenameFailure and TestReaderNeverSeesPartialFile; dropping the sweep call fails TestTempFilesAreSwept. One honest gap: no test covers the Sync. Its failure mode is a machine losing power between the rename and the data reaching the platter, and a harness for that does not exist here. The call stays because the pattern is only correct with it. docs: none - internal package, no user-facing surface
Records the two mutations that prove the tests can fail, and the one gap they cannot cover: nothing here tests the Sync, because its failure mode is power loss and no harness produces that. docs: none - task record under .procoder/, no user-facing surface
Third task of the state seam (#117 phase 0). The only repository key procoder has is the filesystem path from tools.RepoRoot, and a path means different things on different machines. One daemon serving ten checkouts cannot tell them apart with that. The ladder is fixed: the configured value, then the origin remote, then the first remote in alphabetical order, then the resolved absolute root path. It never fails and never returns an empty key, because an empty identity is one every repository would share. origin beats an alphabetically earlier remote deliberately. Pure alphabetical order looks simpler and is wrong: a colleague who adds a personal remote named "fork" would key the same repository differently from everybody else, which defeats the one thing an identity is for. That was caught in review of the spec, not in the code. The rung comes back with the key. A key that surprises somebody has to be traceable to the thing that decided it, or the only way to explain an unexpected identity is to guess. gitx.Remotes takes the remote name from between the first and last dot of the config key rather than by splitting on dots, because a remote may be named "up.stream". It returns an empty map for a directory with no remotes and for one that is not a repository: absence is the answer at that rung, not a fault, and treating it as one would make a fresh git init look broken. Mutation-checked: dropping the host lower-casing fails TestIdentityNormalisation on the uppercase-host case; removing the origin preference makes the origin-plus-fork case return the fork; testing cfgRepo for the empty string instead of trimming yields a whitespace key; using the raw path instead of EvalSymlinks gives two keys for one checkout reached through /var and /private/var; splitting the config key on dots loses the "up.stream" remote; removing the service.repo case leaves ServiceRepo empty. docs: none - internal packages and one config key not yet documented, which task 4 covers when the key becomes visible in `procoder config`
…e from Fourth task of the state seam (#117 phase 0). The identity added in the last commit had no consumer until the daemon exists, which would have left it as untested new work sitting on phase 1's critical path. Printing it makes it observable and checkable now, with nothing running. It is printed below the settings table and deliberately not in it. The table is settings: each has a default and can be relaxed from it. The identity has neither — it is computed, or stated — and a row there would invite somebody to read it as a knob that had been loosened. The line carries the rung as well as the key, because a surprising identity with nothing to explain it leaves guessing as the only recourse. docs/configuration.md gains the [service] section: the ladder, the URL normalisation, why origin beats an alphabetically earlier remote, and the cases where setting the key by hand is the right answer.
Six mutations for the identity ladder and one for the report line, each naming the edit and the failure it produces. Also records why Identity.Source() landed in task 3 rather than task 4: the four rung wordings deserve a test of their own rather than being asserted only through the report's output. docs: none - task records under .procoder/, no user-facing surface
Fifth task of the state seam (#117 phase 0). dispatch, claims, envsync, learn and the hook's handoff and markers lose their direct filesystem calls. Every exported signature is unchanged; only the plumbing moved. The payload is bytes rather than each owner's own type. A store returning []dispatch.Wave would have to import dispatch, which imports the store — an import cycle. Marshalling stays where it already was; the operations are still named per owner, which is what lets the daemon lock and order them. AppendLearn reads, appends and writes under one lock rather than holding an O_APPEND handle. O_APPEND is atomic only up to the pipe buffer and only on some filesystems, and "usually" is not a property a measurement can be built on. TWO REAL BUGS CAME OUT OF THIS. The first is the one the task existed to fix, and TestConcurrentAppendsBothSurvive found it immediately — but it found more than expected. With the lock in place, 20 concurrent appends still lost six. The cause was in the lockfile itself: O_EXCL creates the file empty and the pid and timestamp are written a moment later, so for that moment every live lock has contents that do not parse. "Unparsable means stale" then let a second caller steal a newborn lock, and two holders is exactly the defect this package exists to remove. Staleness is now decided by the file's own mtime first, and the contents get a say only when they parse. TestNewbornLockIsNotStolen pins it, and the spec criterion that said unparsable contents are stale was corrected rather than left to read as though the first attempt had been right. The second is smaller: envsync had hand-rolled its own temp-and-rename. That is now the store's, which also takes the file's lock — the same guarantee, plus the one it never had. learn's applied-proposal marker was not in the plan's enumeration of the state files and is now routed too; blockedPath in the hook fell out as dead code and is deleted. docs: none - internal packages, no user-facing surface
The criterion "a lock file whose contents do not parse is treated as stale" was wrong as written, not merely incompletely implemented. O_EXCL creates a lock file empty and the pid and timestamp land a moment later, so for that moment every live lock is unparsable, and the rule handed two callers the same lock. Corrected in all three places rather than only in the code, so nothing here reads as though the first attempt had been right. The lockfile task's record carries the correction under its own heading, dated after its close, because that is when it was found — by task 5's concurrency test, six lost appends after the lock was supposedly in place. docs: none - design records under .procoder/, no user-facing surface
Task 7a of the state seam (#117 phase 0), split out of task 7 and moved ahead of task 6. The plan put every guard last. That is the wrong order for this one. The parity harness is the only thing that would catch a behaviour change in task 6's twenty-package migration, and building it afterwards means the riskiest task in the plan runs with no net under it. The structural guards still come last, because they cannot pass until task 6 is done. The goldens for status, the principles hook and the handoff note are byte-identical to the output of a binary built at c4bb353 — the commit before internal/store existed — checked with diff against that binary. That equality is the whole value: a golden regenerated from current code asserts only that the code does what it does. The config golden is captured from current code instead, because task 4 changed that output on purpose, and it guards drift from here rather than parity with before. TestCapturesAreDeterministic earned its place immediately. The handoff note stamps the wall clock, so the first goldens failed one second after they were written. The timestamp and the config report's absolute root path are now replaced by line rather than dropped, so the fact that each line is printed is still asserted. `procoder check`, the PreToolUse hook and the PostToolUse hook are deliberately NOT captured. All three exec gitleaks, semgrep, golangci-lint or the test runner, whose presence and version vary by machine; a golden of those would pin one laptop rather than procoder's behaviour and would fail in CI for reasons no change caused. The spec now says so in Out of scope rather than leaving the omission to be discovered. The harness is an external test package: it drives status, principles, the stop hook and the config report, and all of those import internal/store, so an in-package test importing them back would be a cycle. docs: none - test harness, no user-facing surface
Records the diff against the c4bb353 binary that makes the goldens a parity assertion, and why config.txt is not one of them. docs: none - task record under .procoder/, no user-facing surface
Sixth and last migration task of the state seam (#117 phase 0). Twenty-odd packages lose their direct filesystem calls: adr, analysis, answers, ask, backlog, bench, codeindex, config, docs, glossary, gitcmd, lessons, lint, plan, principles, review, spec, status, templates, todo and wizard. Every directory owner now goes through ListDir/LoadIn/SaveIn and every single-file owner through LoadDoc/SaveDoc. Locking stays per file, not per directory: two people editing two different stories have no reason to wait for each other. Three helpers earned their place rather than being designed in advance. Rel exists because several packages list .procoder/ files as ABSOLUTE paths and hand them around — spec.Files, plan.Files, analysis.Files, Item.Path, Task.Path — and their readers would otherwise have to reach past the store to open what they were given. It refuses a path outside root, so "read what I was handed" cannot become "read anything". OpenIn exists because the index's tag file runs to megabytes and two readers scan it line by line. Handing them the bytes would undo that. Reads take no lock in any case, so a handle is no weaker than a ReadFile. inDir refuses a name containing a separator, as markerPath already did. Nothing passes a hostile name today, but joining an unchecked name lets ".." walk out of the directory the caller named. TWO MORE HAND-ROLLED ATOMIC WRITES FOUND AND REMOVED. internal/ask had its own temp-and-rename for the answers file, and the code index had another for its tag file. Both are now the store's, which does the same thing and also takes the lock neither had — so an index Refresh firing from the write hook can no longer race an `index build` in another session. One rename that is not cosmetic: internal/ask had parameters named `store`, which shadowed the package. Renamed to `decided` and `a`. The parity goldens from c4bb353 still pass, which is what says none of this changed what procoder does. docs: none - internal packages, no user-facing surface
Task 7b of the state seam (#117 phase 0), and the last of it. No production code — this is the evidence that the previous six did what they claimed and the thing that keeps them true. TestNoDirectProcoderFileIO walks internal/ and cmd/ with go/ast and fails on any os.ReadFile, WriteFile, Create, CreateTemp, OpenFile, Open, ReadDir or Rename whose argument names a .procoder/ path — either as a literal or through a constant the package declares as one. It found the last holdout, status reading the index's meta.json, and one false positive worth naming: the self-upgrade's `.procoder-upgrade-*` temp directory, which is a name and not a path. The prefix test now matches `.procoder/` or exactly `.procoder`, never `.procoder-`. os.Stat is deliberately not in that list. Asking whether something exists is not reaching past the store, and gate adoption legitimately stats .procoder/ to decide whether a repository opted in. TestStoreCoversEveryPathConstant holds a list somebody has to edit. A new .procoder/ path is a new thing procoder owns, and it should not be possible to add one without deciding which store operation serves it — that is a decision, not a detail. Each entry names the operation, and four say plainly that they are prose or a gitignore prefix rather than a path. TestNoModuleDependencies reads go.mod. The lockfile in this package is an O_EXCL file rather than flock because a portable flock/LockFileEx pair costs golang.org/x/sys, and that reasoning is only worth anything while the module really has no dependencies. So the claim gets a test rather than a comment. Mutation-checked, snapshot taken and restored around each: adding a direct os.ReadFile on .procoder/docs/RULES.md fails the IO guard; removing one entry from the known-paths list fails the coverage guard; adding a require block to go.mod fails the dependency guard. docs: none - test-only, no user-facing surface
The content-owner record names the three helpers the plan had not anticipated and why each was needed, and the two further hand-rolled atomic writes the migration turned up. The guard-test record includes a mutation that was NOT valid — reverting the status.go call broke the build on unused imports rather than failing the test, so it proved nothing. The valid one is recorded beside it. docs: none - task records under .procoder/, no user-facing surface
Found by the pre-PR review, and it is the defect this package exists to
remove, so it is worth stating plainly: judging a lock stale and removing
it were two operations with nothing between them. Two callers that both
judged the same lock stale interleaved — the first removed and re-created,
the second removed what the first had just created — and both then held.
Only the holder of a break file may now remove a stale lock, so the second
caller never reaches its remove. TestBreakingAStaleLockIsSerialised proves
it deterministically; TestOnlyOneCallerEverHoldsALock is a stress test
beside it and is honestly NOT the proof, because the window is two
syscalls wide and sixteen goroutines rarely land in it. An orphaned break
file is cleared by age, and that has its own test.
Three more from the same review:
- A HELD lock was broken at thirty seconds. The mtime was set once at
creation and nothing refreshed it, so a write that legitimately runs
longer — rewriting a multi-megabyte tags.jsonl on a slow filesystem —
had its lock taken while it was still writing. A heartbeat now touches
the file every ten seconds, and the file's mtime became the SOLE
liveness signal: the timestamp inside the lock records when it was
taken and nothing refreshes that either, so ageing it condemned exactly
the locks the heartbeat exists to protect.
- Release removed by path without proving ownership, so a holder whose
lock had been broken would delete whichever live lock replaced it.
It now checks os.SameFile against the file it actually created.
- The rename was not fsynced. The data was, but the directory entry was
not, which is the "rename that outlives its own data" the comment
claimed to prevent.
And two the review raised as cuts rather than defects, both taken:
- forceRenameFailure is gone, and with it the rename() wrapper it needed.
A file cannot be renamed over a NON-EMPTY DIRECTORY on any platform we
ship, which is how the atomicity test fails the rename now. An
atomicity claim nothing can check is not worth making; one that needs a
flag in the shipping binary to check is worse.
- The sweep's cutoff was staleAfter under a second name. It is an hour
now, and for a reason rather than to be different: the sweep runs in
one process against files another may still be writing, so the only
way to be sure a temp file is litter is for it to be far older than any
live write could be. It also runs once per directory per process
instead of after every save, which was a full ReadDir of .procoder/todo
on every task close.
docs: none - internal package, no user-facing surface
Three holes in normalise, all found by the pre-PR review and all of them
the same failure — two repositories keying the same, which is precisely
what an identity exists to prevent.
- A port was left in the authority, so https://host:8443/o/r and
https://host/8443/o/r produced one key, as did
ssh://git@host:2222/o/r.git and https://host/2222/o/r. A numeric port
is now stripped, and a colon is only read as the scp separator when
what follows it is not all digits.
- The user was stripped by searching the WHOLE url for "@", so
https://host/o/r@2 returned "2" — the host thrown away and the key a
bare digit. The search is bounded to the authority now.
- A url that reduced to nothing still answered. normalise("") and
normalise("https://") returned "", and IdentityFor handed that back as
a real identity, which every repository with a malformed remote would
then share. An empty result now means the rung did not answer and the
ladder continues to the next one.
The path rung is slash-separated and absolute, so the key for a checkout
is the same string on Windows as anywhere else rather than differing by
platform for the same repository.
The host is lower-cased and the path deliberately is not, which the doc
comment now says and gives the reason for: two hosts differing only in
case are the same host, always, while two repositories differing only in
the case of their path are not — and merging two repositories onto one key
is the worse of the two failures.
docs: none - internal package; the user-facing ladder is already in
docs/configuration.md and is unchanged by these fixes
The pre-PR review demonstrated both halves. It REFUSED a legitimate path: on macOS a temp root is /var/... while anything that has called EvalSymlinks holds /private/var/..., so comparing the two said "outside" and every caller — spec.Check, plan.Check, analysis.Check, todo.CloseWith, backlog.CloseStoryWith — reported a perfectly readable file as unreadable. And it PERMITTED an escape: a symlink inside the root pointing out of it produced a clean-looking relative path that the store then read and wrote through. Both operands are resolved now, with the nearest existing ancestor used for a path that does not exist yet so a first write is not refused for not having happened yet. Rel(root, root) returned ".", which flowed into LoadDoc(root, ".") and SaveDoc(root, ".") — a read of, or a write over, the repository root. It is refused alongside the dot-dot cases. The review also found readUnder copied verbatim into five packages and writeUnder into two more. They are store.LoadUnder and store.SaveUnder now, declared once. One real miss came out of the hardened IO guard rather than from reading: internal/hook read .procoder/ask/decisions.md through a local variable, which the old guard could not see. docs: none - internal packages, no user-facing surface
The pre-PR review bypassed it twice, and both bypasses now fail the test. The constant map was built PER FILE while Go constants are package-scoped: a call in one file using a Dir declared in another passed. It is built per package now. And a path held in a local variable was invisible — `p := filepath.Join(root, Dir, name)` followed by `os.ReadFile(p)` sailed through. Assignments whose right-hand side names a .procoder path now taint the variable, and an IO call using a tainted variable is flagged. That second change immediately found a real one: internal/hook reading the decisions file through exactly that shape. Also from the review: os.Remove, os.RemoveAll, os.Truncate, os.Chmod, os.Symlink and os.Link are guarded now — a delete past the store is as much a write as a write is. os.Stat stays out on purpose, and the comment says why: asking whether something exists is not reaching past the store, and gate adoption legitimately stats .procoder/ to decide whether a repository opted in. The hand-rolled word-boundary matcher is a compiled regexp, cached per name. docs: none - test-only, no user-facing surface
Two findings from the pre-PR review. The store re-declared five paths that dispatch, claims, envsync, learn and the stop hook also declared. Two declarations of one path drift, and the drift is silent until one writes where the other no longer reads. The store exports them now and the owners name them, which makes drift impossible rather than merely detectable; learn and the stop hook compose theirs from parts, so those two get a test instead. AppendLearn was a full read-modify-write of an unbounded file. learn.Append runs on EVERY procoder invocation and nothing trims the record file, so each command was paying for the length of its own history — the old O_APPEND write was O(1). It is O_APPEND again, under the lock: the LOCK is what makes the append safe now, not O_APPEND's atomicity, so rewriting the whole file to add one line bought nothing and cost everything. markerPath was inDir character for character, and is now one line calling it. docs: none - internal packages, no user-facing surface
The pre-PR review caught the command reference describing the settings table only, while the command now ends with the identity and the rung that produced it. Says what the line is for, says why it sits below the table rather than in it — it has no default to be relaxed from, so it is not a setting — and links the ladder in the configuration page. docs: docs/commands.md
… own rule Three findings from the verification pass, all of them consequences of the previous commit rather than pre-existing. `go test -race ./internal/store/` FAILED. keepAlive read staleAfter on its own goroutine, so every test that restored it raced the heartbeat, and Notice was the same shape from four tests. CI does not run -race, so this would not have turned the pipeline red — it would simply have meant nobody could use the detector on the one package where they most want to. The tick is read on the caller's goroutine and passed in; the tests set Notice once for the package. The heartbeat touched by PATH with no identity check. If our lock were ever broken — one failed os.Chtimes is enough — it carried on refreshing whatever file now sat there. That file belongs to somebody else, and our ticker would keep it looking alive for as long as this process ran, so if THAT owner died its lock would never be judged stale and the path would wedge until we exited. A heartbeat for a lock we no longer hold is worse than no heartbeat. It stops when os.SameFile stops matching. And the unguarded stat-then-remove I had just removed from the lock was reintroduced on the break file, in both of its removes. Two callers could therefore be inside the section that exists to hold one. The double-HOLD did not follow, because each entrant re-checks stale(p) and only one remove can win — but the mutual exclusion the scheme is named for was gone, which is not a thing to ship because its consequence happens to be bounded. Both removes are os.SameFile-guarded now, through one helper, because every unguarded stat-then-remove in this package has turned out to be a way for one caller to delete another's file. Two smaller ones: the release closure panicked if called twice, and now uses sync.Once; and brief() set the staleness window to 60ms, which is below the mtime granularity of HFS+, exFAT and several container overlay filesystems, so the heartbeat test would have failed there for a reason nobody changed. Three seconds now, with the sleeping saved in lockTimeout where it actually was. docs: none - internal package, no user-facing surface
A regression this branch introduced and the verification pass caught. Returning "" for a url that reduces to no host was right for a malformed remote and wrong for a real one: /srv/git/repo.git and file:///srv/git/repo.git have no host either. The origin rung then did not answer, two clones of one bare shared remote keyed by their own checkout paths, and `procoder config` reported "no remote — repository root path" when there plainly was a remote. Two identities for one repository is the exact divergence identity exists to prevent, and the pre-seam code did not have this problem. A path-shaped remote is now its own form. The key is the path of the REMOTE, which both clones agree on, rather than of either checkout. A RELATIVE remote deliberately still does not answer: it means something different from each clone's own directory, so it cannot key them together and pretending otherwise would be worse than falling through. Four more edges from the same pass, each two keys for one repository: doubled slashes survived, a .GIT suffix was kept because the trim was case-sensitive while the hosts serving it are not, and a query or fragment reached the key. The bracketed IPv6 scp form was mangled outright — [::1]:o/r came back as [/:1]:o/r — because the separator scan landed inside the brackets. docs: none - the user-facing ladder in docs/configuration.md is unchanged
Two from the verification pass.
resolve had no fixed point. Clean("/") is "/", so a filesystem root that
EvalSymlinks cannot resolve — a Windows drive that is not ready — called
itself with the same argument until the stack ran out. Unreachable on Unix,
where EvalSymlinks("/") always succeeds, which is exactly the kind of
unreachable that stops being unreachable on somebody else's machine.
saveContent had become byte-identical to save: same signature, same three
statements, two files apart. That is the drift the store exists to prevent,
happening inside the store. One of them now, and its comment says it covers
both halves of the package.
docs: none - internal package, no user-facing surface
The verification pass planted four bypasses and the guard passed all four. Three are fixed and the fourth is documented rather than left to be found. A path assigned to a STRUCT FIELD was invisible, because the left-hand side is a selector and not an identifier. That is the shape of todo.Task.Path and backlog.Item.Path, both of which hold .procoder absolute paths today, so os.ReadFile(t.Path) was the likeliest future regression and the guard would not have seen it. An ALIAS laundered taint — p := filepath.Join(root, Dir, name); q := p — because the right-hand side was only checked for .procoder names, never against what was already tainted. Taint propagates now, and the pass runs to a fixed point so a chain of aliases does not launder it either. A RANGE VARIABLE was never tainted, because a RangeStmt is not an AssignStmt. The fourth, a helper that RETURNS a .procoder path, is not caught. Chasing return values wants a pass over the package's call graph, which is more than this guard is worth — so the source says so plainly. A documented gap is not a gap that fails open by accident. docs: none - test-only, no user-facing surface
learn shipped maxRecords = 5000 in 3baaac6, with a comment saying it "bounds the file so an old repository does not carry an unbounded one. Oldest dropped." Nothing ever referenced it. Rotation was specified and never wired, so learn.jsonl grows without limit wherever `[learn] record = true` is on. The verification pass found it because this branch's own AppendLearn comment now contradicted it in the same tree. Deleting the constant does not fix the gap — it stops the tree lying about the gap, which is the part this branch is answerable for. The gap itself gets a task rather than a fix here. It predates the seam and is not the seam's to solve: this branch is about where reads and writes go, not about what learn retains. The task records why the seam is nonetheless the natural home for the fix — store.AppendLearn already holds the file's lock, which is exactly what a safe trim needs — and requires that appending stay O(1) in the common case. docs: none - the bound will be documented when it exists, per the task
Found while proving the state seam changed no behaviour: it was the one file of thirty-seven whose bytes differed between the old and new binaries. Checking whether the OLD binary agreed with ITSELF is what showed it was not a regression — it never has agreed with itself. The content is equivalent; canonicalising the JSON makes the two compare equal, so what varies is element order from map iteration. Same class as #236 and #245, and unnoticed only because the index is gitignored. docs: none - task record under .procoder/, no user-facing surface
Comparing a binary built at c4bb353 against HEAD across 245 invocations — the whole command surface, every write path, all four hooks, and nineteen broken-input scenarios — turned up exactly one difference that is not the new `repo identity` line. With `.procoder/state/` read-only and the rest of the tree writable, `procoder ask` used to write `.procoder/ask/QA.md` and now refuses. Every file's lock lives under `.procoder/state/locks`, so an unwritable state directory stops writes to content that has nothing to do with state. The condition is narrower than it first reads, and the test says so: it bites only while `.procoder/state/locks/` does not yet exist. Once created — which the first successful write in a repository does — a read-only state directory blocks nothing, because no new entry has to be made inside it. `dispatch` and `env --sync` refuse on BOTH binaries in that situation, since their targets are themselves inside the frozen directory; only their messages differ. Accepted deliberately rather than worked around, and the spec now says why. Locks beside their files show up in git status and in review. Locks in the OS temp directory stop being mutually exclusive the moment two processes have different TMPDIR values, which on macOS is the ordinary case across sessions — and a lock that silently stops locking is the one failure this package cannot have. TestReadOnlyStateBlocksContentWrites pins it, so it stays a decision rather than becoming a surprise. docs: none - design record under .procoder/; no user-facing surface changed by this commit
The branch's first CI run failed on windows-latest while ubuntu and macOS
passed. main is green, so every one of these is this branch's.
ONE REAL PRODUCT BUG. Windows reports a file that is pending deletion as
"Access is denied" rather than "already exists", so `!os.IsExist(err)`
treated ordinary lock contention as fatal: TestConcurrentAppendsBothSurvive
lost five of twenty appends there. A create that failed is not proof the
lock is unobtainable — only the deadline is — so every create error now
retries until the deadline, and the deadline message carries the cause
when it is not plain contention, because a permissions problem that spent
five seconds should not read the same as a busy file.
A SECOND, quieter one: release removed the lock while the heartbeat could
still be mid-os.Chtimes, and Windows will not remove a file another handle
has open. The removal then failed silently and the lock sat there until the
next caller had waited out the whole timeout — which is what
TestLockOrderIsSortedPaths hit. Release now waits for the heartbeat to
actually return before removing, and the removal is retried.
The rest were tests written against POSIX and not against the tree:
- The two read-only-directory tests cannot work on Windows at all.
os.Chmod there toggles a file's read-only attribute and does not
restrict a directory, so there is no read-only directory to test
against. They skip, and say that rather than implying coverage.
- The identity path-rung tests compared against a native path while
IdentityFor deliberately returns a slash-separated one — so the key
for a checkout is the same string on every platform. The product was
right and the tests had not been updated.
- Every golden comparison failed for a difference nobody wrote: a
Windows checkout converted the committed goldens to CRLF while
procoder emits LF everywhere. .gitattributes pins them, which fixes
the cause rather than teaching the comparison to ignore it.
docs: none - internal package and test-only changes, no user-facing surface
The one gap the equivalence sweep and the CI matrix both left open: a filesystem that stores a truncated mtime. HFS+, exFAT and several container overlay filesystems keep it to one or two second granularity, so a lock touched a moment ago can READ as older than it is — and mtime is now the sole liveness signal. Two tests, and the first is honest about what it is NOT. It cannot prove behaviour on a filesystem this machine does not have; it is an invariant test. What it does is pin the relationship — one heartbeat interval plus one granularity has to stay comfortably under staleAfter — so nobody shortens the window or lengthens the heartbeat without noticing the margin went with it. Setting staleAfter to the 3s that brief() uses fails it, which is the check working. The second is direct: take a real lock, truncate its mtime to a two-second boundary the way a coarse filesystem would store it, and assert it is not judged stale. docs: none - test-only, no user-facing surface
The reflection step, and the escapes are worth naming because of what did
NOT catch them.
Two fresh-context review passes read this whole diff. A 245-invocation
equivalence sweep compared every command, every write path and nineteen
broken-input scenarios against the pre-change binary. `go test -race`
passed. All of it ran on macOS, and none of it could have found a lock
that fails on Windows because a file pending deletion reports "access is
denied" rather than "already exists". CI's Windows leg found it, late,
after the PR was open.
Three classes, three rubric lines, three ledger entries:
- POSIX assumptions in code and tests — `os.IsExist` alone on a create
error, removing a file another handle may hold, `os.Chmod` on a
DIRECTORY to make it unwritable, and native-versus-slash path
comparisons. Each is a shape a reader can look for; none of them is
something a macOS test run can fail on.
- Committed fixtures compared byte for byte against generated output
need their line endings pinned, or a Windows checkout converts them
and every comparison fails on a difference nobody wrote.
- Check-then-act on a shared file. The pre-PR review found this once, on
the lock; the verification pass found the identical shape reintroduced
on the break file ONE COMMIT after the first was fixed. Fixing an
instance does not close a class, and "is this race fixed" is a
different question from "does this shape appear anywhere".
`procoder lessons` reports 41 lessons, 0 unlearned. `procoder copilot-leak`
reports nothing since the window opened — Copilot's review of this PR
returned only that it had hit its quota, so it contributed no findings.
docs: none - the review rubric and lessons ledger under .procoder/, no
user-facing surface
piwi3910
added a commit
that referenced
this pull request
Sep 3, 2026
The reflection #266 shipped without. Copilot found two real defects on that PR, and both were in code written to satisfy an earlier finding. The pre-PR review found that a formatted body could starve the secret scan; the fix was a must-keep path, and that path shipped with two defects of its own. Keep parts were placed against the full budget rather than the reserved one, so the omission notice could push the payload past the very constant it exists to enforce. And a keep part larger than the budget was dropped without being counted, so the one finding declared un-droppable vanished silently — the exact shape the notice was added to prevent. Neither is subtle. They are eleven lines written in direct response to a review finding, on the path that finding said was the important one, and nothing read them, because the reviewer had already run. A review is a snapshot of the diff as it was; everything written to satisfy it is newer than the review. The same session had already demonstrated the fix and then not applied it: during #249 a verification pass over the fix diff caught a race reintroduced one commit after the original was fixed. That pass was not run for #266. Two rubric lines in REVIEW.md, because the general shape and the concrete one are both worth looking for: the fixes made for an earlier finding read as new code, and a budget that excludes its own overhead — where a tolerance in the test is how the overflow passes, which is how it passed here. Plus the ledger entry. procoder lessons: 42 lessons, 0 unlearned.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every read and write under
.procoder/now goes through one package,internal/store, which locks the file, writes it atomically, and knowswhich repository it is serving. Twenty-six packages lose their direct
filesystem calls; none of them changes an exported signature, and none of
them changes what procoder prints.
One thing is visible to users:
procoder configgains arepo identityline, and
.procoder/config.tomlgains an optional[service] repokeythat overrides the computed answer.
This is phase 0 of #117 — the seam the local daemon needs. There is no
daemon here, no socket, no server, and nothing that uses the seam yet.
Why
Issue #117 proposes running procoder as a service. A daemon serving
several sessions and several repositories cannot be built on what is here
today, because of three defects that are already real and merely rare:
Nothing locks. There was no locking anywhere in
internal/— the onlymutex in the tree was in a test.
dispatch.json,claims.jsonand the askledger are read-modify-write: load the whole file, change one field, write
it back. Two writers lose one of the two updates, silently. That is rare
only because every hook is its own short-lived process.
Nothing is atomic. Every write was an
os.WriteFile, which truncatesfirst. A process killed in that window leaves a truncated
claims.json,and the next reader reports a corrupt ledger for a crash that had nothing
to do with it.
There is no repository identity. The only key procoder had was the
filesystem path, which means something different on every machine. One
daemon serving ten checkouts cannot tell them apart with that.
Fixing them now, with no daemon in the picture, is cheaper than fixing
them as the daemon's first three bugs.
How
internal/storehas three primitives and one named operation per owner ontop of them.
The lock is an
O_EXCLlockfile under.procoder/state/locks/, notflock.go.modhas norequireblock at all, and a portableflock/LockFileExpair costsgolang.org/x/sys; procoder is notspending its first dependency on this.
TestNoModuleDependencieskeepsthat claim honest. Locks are taken in sorted path order regardless of the
order the caller asked for, so two callers wanting the same two files
cannot deadlock. A failed multi-lock releases what it already took.
The stale rule is the part worth reviewing. A lock is dead when its
own mtime is older than 30s; the contents get a say only when they parse.
That is not where it started — see the note below.
Writes stage into a temp file in the destination directory (rename is
only atomic within a filesystem), fsync, then rename over the target. A
reader sees the whole old file or the whole new one; a failure leaves the
old one untouched.
Identity resolves down a fixed ladder:
[service] repo, then theoriginremote, then the first remote alphabetically, then the resolvedabsolute root path. It always answers and never returns an empty key.
originbeats an alphabetically earlier remote deliberately — purealphabetical order is simpler and wrong, because a colleague who adds a
personal remote named
forkwould key the same repository differentlyfrom everybody else, which defeats the one thing an identity is for. The
rung comes back with the key, so a surprising identity is traceable rather
than mysterious.
The payload is bytes, not each owner's type. A store returning
[]dispatch.Wavewould importdispatch, which imports the store — animport cycle. Marshalling stays where it already was; only the filesystem
call moved.
A bug found mid-build, and the spec correction it forced
Task 5's concurrency test found a defect in the lock written three commits
earlier — one that had already been mutation-checked and closed.
O_EXCLcreates the lock file empty; the pid and timestamp are writtena moment later. So for that instant, every live lock has contents that do
not parse. The original rule — unparsable contents mean stale — let a
second caller break a lock created a microsecond earlier and take it. Two
holders of one lock, which is precisely the defect this package exists to
remove. It showed up as six of twenty concurrent appends still going
missing after the lock was supposedly in place.
The acceptance criterion was wrong as written, not merely wrongly
implemented. The spec, the plan and the already-closed task record were all
corrected (e440d41), the last under a dated correction heading, so none of
them reads as though the first attempt had been right.
Three helpers the plan did not anticipate
Rel— several packages list.procoder/files as absolute pathsand hand them around (
spec.Files,plan.Files,analysis.Files,Item.Path,Task.Path). Their readers would otherwise have to reachpast the store to open what they were given.
Relrefuses a path outsideroot, so "read what I was handed" cannot become "read anything".
OpenIn— the index's tag file runs to megabytes and two readersscan it line by line. Handing them the bytes would undo that.
inDir/markerPath— refuse a name containing a separator.Nothing passes a hostile name today, but joining an unchecked name lets
..walk out of the directory the caller named.Four hand-rolled atomic writes found and removed
envsync,internal/ask,internal/codeindexand the indexRefresheach had their own temp-and-rename. All four are now the store's, which
does the same thing and also takes the lock none of them had — so an index
Refreshfiring from the write hook can no longer race anindex buildrunning in another session.
Testing
go test ./...green.procoder checkclean on every commit — the commitgate ran on all fifteen.
Parity is proven, not asserted.
TestMigrationOutputUnchangedcomparesprocoder status, the SessionStart principles hook, the Stop hook'shandoff note and
procoder configagainst committed goldens. The firstthree goldens were captured from a binary built at
c4bb353— the commitbefore
internal/storeexisted — in a throwaway worktree, anddiffreported no difference. That equality is the whole value: a golden
regenerated from current code only asserts that the code does what it does.
The
configgolden is captured from current code instead, because thatoutput changed on purpose.
TestCapturesAreDeterministicearned its place immediately: the handoffnote stamps the wall clock, so the first goldens failed one second after
being written. That line and the config report's absolute root path are now
replaced by line rather than dropped, so the fact that each is printed is
still asserted.
Functional equivalence against the pre-seam binary
The goldens cover four captures. That is not the same as "the CLI still
does what it did", so the whole surface was compared: a binary built at
c4bb353and one built at HEAD, run against fresh identical fixturescarrying a file in every
.procoder/owner, with volatile values held outby line.
.procoder/after exercising every write pathindex buildprocoder checkand all four hooks (both PostToolUse and both PreToolUse shapes).procoder/at all, with and without gitconfigline, 2 investigated below245 comparisons. Two differences that are not the
repo identityline, one of them mine.
The write paths matter most here, because that is what the store changed:
dispatchopen/start/seal/return,claimsadd/list/release,env --sync,index build,bench --save,sprint open,todo close,backlog close story,ask, and all four hooks were run in sequence, then the resultingtrees compared file by file.
One was my test harness, not the product. The
binary-specscenariofilled the file from
/dev/urandom, so the two fixtures never held thesame bytes. Re-run with identical bytes, every command matches.
One is a real behaviour change, and it is the only one in the diff.
With
.procoder/state/read-only and the rest of the tree writable,procoder askused to write.procoder/ask/QA.mdand now refuses: everyfile's lock lives under
.procoder/state/locks, so an unwritable statedirectory stops writes to content that has nothing to do with state.
The condition is narrower than it first reads — it bites only while
.procoder/state/locks/does not yet exist, and the first successfulwrite in a repository creates it.
dispatchandenv --syncrefuse onboth binaries there, since their targets are themselves inside the
frozen directory; only their messages differ.
Accepted deliberately rather than worked around, and the spec says why.
Locks beside their files show up in
git statusand in review. Locks inthe OS temp directory stop being mutually exclusive the moment two
processes have different
TMPDIRvalues — on macOS the ordinary caseacross sessions — and a lock that silently stops locking is the one
failure this package cannot have.
TestReadOnlyStateBlocksContentWritespins it, so it stays a decision.The one file that differed was not a regression.
.procoder/index/refs.jsoncame out byte-different — but the OLD binary produces a different
refs.jsonon every run too, which is what checking it against itselfshowed. Canonicalising the JSON makes the two compare equal, so what varies
is element order from map iteration somewhere between SCIP and the merged
file. Pre-existing, filed as its own task, and the same class as #236 and
#245 — unnoticed only because the index is gitignored.
Three structural guards keep the seam from eroding: no direct
.procoder/file IO outside the store (go/ast walk), every.procoder/path literal listed with the operation that serves it, and no module
dependencies.
Twenty-one mutations run, each with a snapshot taken immediately before and
restored immediately after. Every one was caught. Examples: dropping the
sort deadlocks the ordering test; emulating the old truncate-first write
fails both atomicity tests; removing the origin branch makes
origin-plus-fork return the fork; splitting the git config key on dots
loses an
up.streamremote; adding a directos.ReadFileon a.procoder/path fails the IO guard.Three mutations were not valid and are recorded as such rather than
counted. Reverting a
status.gocall toos.ReadFilebroke the build onunused imports instead of failing the test. Removing the break-file guard
with
if falseleft a variable unused and did the same. And asedfor theheartbeat line matched the wrong indentation and silently changed nothing,
which looked exactly like a passing mutation — the reason to read what a
mutation actually did rather than trust its exit code.
What the pre-PR review found
A fresh-context reviewer read the whole diff before this was opened and
returned 1 Critical, 8 Important, 7 Minor. Every Critical and Important
is fixed in the six commits at the head of this branch. The Critical is
worth reading in full because it was in the part of the package that exists
to prevent exactly it.
Critical — two callers could break one stale lock and both hold it.
Judging a lock stale and removing it were two operations with nothing
between them. Two callers that both judged the same lock stale interleaved:
the first removed and re-created, the second removed what the first had
just created, and both then held. Breaking is now serialised through an
O_EXCLbreak file, so the second caller never reaches its remove.TestBreakingAStaleLockIsSerialisedproves it deterministically —TestOnlyOneCallerEverHoldsALocksits beside it and is documented in thesource as not the proof, because the window is two syscalls wide and
sixteen goroutines rarely land in it.
The eight Important, each fixed:
os.SameFileagainst the file it createdsyncDirafter the rename, best efforthost:8443/o/randhost/8443/o/rone key@searched the whole URL, sohost/o/r@2returned2Relignored symlinks — refused legitimate paths and allowed an escape through a link inside the rootAppendLearnwas an unbounded read-modify-write on every commandO_APPENDunder the lock againThe IO guard was itself failing open, two ways, and the reviewer
bypassed it twice: constants were collected per file while Go constants
are package-scoped, and a path held in a local variable was invisible. Both
bypasses now fail the test — and the taint tracking immediately found a
real miss in
internal/hookthat reading had not.Seven of the eight simplification findings were taken, including deleting
forceRenameFailureand its wrapper from the shipping binary. One wasdeclined and is recorded here rather than silently:
Lockstays variadicwith sorted ordering, because
S-5in the spec requires it and has apassing test. That is the shape a YAGNI rationalisation takes, so it is
stated plainly for you to overrule.
And a second pass over the fixes
The same reviewer verified the fixes and returned 0 Critical, 4
Important, 6 Minor. It confirmed the Critical is properly closed and
could not reproduce it. Of the four Important, two were caused by the
fixes themselves, which is the argument for the second pass:
go test -racefailed. The heartbeat readstaleAfteron its owngoroutine, so every test restoring it raced. CI does not run
-race, sothis would never have gone red — it would just have meant nobody could
use the detector on the one package where they most want to.
no identity check, so if our lock was ever broken it went on refreshing
whichever file replaced it — and that lock would then never be judged
stale even after its own owner died.
reintroduced on the break file, in both of its removes. The double-hold
did not follow, because each entrant re-checks
stale(p), but the mutualexclusion the scheme is named for was gone in that window.
file://remote lost its identity entirely — a behaviourregression against
main./srv/git/repo.githas no host, so the originrung stopped answering and two clones of one bare shared remote keyed by
their own checkout paths, with
procoder configreporting "no remote"when there plainly was one.
Every one is fixed, and the six Minor with them:
resolvecould recurseforever on a filesystem root it could not resolve; the release closure
panicked if called twice;
brief()used a 60ms window that is below mtimegranularity on HFS+, exFAT and container overlay filesystems; the IPv6 scp
form was mangled; and doubled slashes, a
.GITsuffix and a query stringeach produced a second key for one repository.
The IO guard missed three more shapes, one live in the tree: a path
assigned to a struct field (
t.Path,it.Path), an alias, and a rangevariable. All three are caught. The fourth — a helper that returns a
.procoderpath — is not, and the source says so; a documented gap is nota gap that fails open by accident.
go test -race ./...is green.Notes for the reviewer
Start at
internal/store/lock.go. The stale rule is the subtlest thinghere and it has already been wrong once.
stale()decides on the file'sown mtime first and only then on its contents;
TestNewbornLockIsNotStolenpins the case that broke.
Then
internal/store/identity.go—normaliseis the other place amistake is expensive, because two different repositories colliding on one
key would be silent.
The twenty-six migrated packages can be skimmed. They are mechanical,
their exported signatures are unchanged, and the parity goldens are what
say so. If you want a sample,
internal/backlogandinternal/todoarethe ones that needed
Rel.Windows found a real bug, and CI is what found it
The first CI run on this branch failed on
windows-latestwhile ubuntu andmacOS passed, and
mainis green — so all of it was this branch's. It isthe clearest argument in the PR for the matrix, because none of the local
work would ever have found it.
Windows reports a file pending deletion as "Access is denied", not
"already exists."
!os.IsExist(err)therefore treated ordinary lockcontention as fatal, and
TestConcurrentAppendsBothSurvivelost five oftwenty appends there — the exact defect the lock exists to prevent, on the
one platform nobody had run it on. A create that failed is not proof the
lock is unobtainable; only the deadline is. Every create error now retries
until the deadline, and the deadline message carries the cause when it is
not plain contention.
A second, quieter one: release removed the lock while the heartbeat
could still be mid-
os.Chtimes, and Windows will not remove a file anotherhandle has open. The removal failed silently and the lock sat there until
the next caller had waited out the whole timeout. Release now waits for the
heartbeat to return, and retries the removal.
Three more were tests written against POSIX rather than against the tree:
the read-only-directory tests cannot work on Windows at all (
os.Chmodtoggles a file attribute there and does not restrict a directory, so they
skip and say so); the identity path-rung tests compared against a native
path while
IdentityFordeliberately returns a slash-separated one; andevery golden failed on a difference nobody wrote, because a Windows
checkout converted the committed goldens to CRLF.
.gitattributespinsthem — fixing the cause rather than teaching the comparison to ignore it.
CI is now green on ubuntu, macOS and Windows.
Known gaps, deliberate
learn's record file has no bound.maxRecordsshipped unwired in3baaac6with a comment claiming a bound nothing enforced. This branchdeletes the constant — it stops the tree lying about the gap, which is
the part this branch is answerable for — and files the fix as its own
task, because what
learnretains is not what this branch is about.f.Sync(). Its failure mode is power loss between therename and the data reaching disk, and no harness here produces that. The
call stays because the pattern is only correct with it.
procoder checkand the two tool hooks are not golden-captured. Allthree exec gitleaks, semgrep, golangci-lint or the test runner, whose
presence and version vary by machine; a golden of those would pin one
laptop rather than procoder's behaviour and would fail in CI for reasons
no change caused. Recorded in the spec's Out of scope.
os.Statis not guarded. Asking whether something exists is notreaching past the store, and gate adoption legitimately stats
.procoder/to decide whether a repository opted in.Two process deviations, both deliberate
The plan's task order was changed before any code was written. It put
every guard last; the parity harness is the only thing that catches a
behaviour change in the twenty-six-package migration, so building it
afterwards means the riskiest task runs with no net. Task 7 became 7a
(harness, before the migration) and 7b (structural guards, after).
This branch is not in a worktree, which
WORKFLOW.mdasks for. It wascommitted on a branch in the main checkout. The branch is clean and the
default branch was never dirtied, but it is a deviation and not worth
hiding.
Docs impact
procoder configprints a new line and.procoder/config.tomlaccepts anew key, so
docs/configuration.mdgained a[service]section (8d60453):the ladder table, the URL normalisation, why
originwins, and when to setthe key by hand. The README describes
.procoder/generically and links tothat page rather than enumerating keys, so it needs no change. Everything
else in this diff is internal plumbing with no reader-visible surface —
which the parity goldens are the evidence for.
Closes nothing on its own; #117 stays open for phase 1 (the local daemon).
Team mode is designed and parked in #248.