Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .abcd/work/issues/open/iss-127-repo-local-lmw-sites-unguarded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
schema_version: 1
id: "iss-127"
slug: "repo-local-lmw-sites-unguarded"
severity: "minor"
category: "tech-debt"
source: "agent-finding"
found_during: "iss-101/102 class sweep (2026-07-24 run queue, burst 3)"
found_at: "internal/core/intent/lifecycle.go"
---

repo-local load-modify-write sites remain unguarded after the iss-101/102 class fix: intent transitions (review.go, lifecycle.go — strongest candidate), ahoy config.json stepConfigValues, gitignore/marker block rewrites, and the CHANGELOG release path all do load-mutate-write without a lock; they lack the home-global cross-worktree exposure the fixed sites had, so risk is same-worktree concurrency only
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
schema_version: 1
id: "iss-128"
slug: "registerrepo-best-effort-edges-lose-signal"
severity: "minor"
category: "bug"
source: "impl-review"
found_during: "iss-101/102 reviews (2026-07-24 run queue, burst 3)"
found_at: "internal/core/ahoy/apply.go"
---

registerRepo's best-effort history paths lose signal silently in two edges the iss-101/102 reviews confirmed: (1) a history-lock contention timeout skips registration with no surfaced note (apply.go registerRepo discards the withHistoryLock error); (2) under concurrent double-install of the same new repo with divergent re-founding answers, the lock loser takes the refresh branch which never consults linkLineage, silently dropping a human-approved lineage link with no lineageConflict message
12 changes: 12 additions & 0 deletions .abcd/work/issues/open/iss-129-consolidate-bespoke-flock-loops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
schema_version: 1
id: "iss-129"
slug: "consolidate-bespoke-flock-loops"
severity: "minor"
category: "tech-debt"
source: "impl-review"
found_during: "iss-101/102 reviews (2026-07-24 run queue, burst 3)"
found_at: "internal/fsutil/flock.go"
---

four bespoke LOCK_EX flock loops remain (memory/writer.go, intent/create.go, spec/store.go, history/store.go) now that fsutil.WithFileLock is the canonical inter-process lock primitive; consolidate them onto it (one-canonical-primitive) — pre-existing, no defect, pure debt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ severity: "minor"
category: "bug"
source: "agent-finding"
found_during: "multi-agent-bughunt"
resolution: "History-index registration now serializes on an inter-process lock in ~/.abcd/history with a re-load inside the lock, and bootstrap seeds index.json via an atomic exclusive hard link; the re-founding prompt is asked before the lock and its state re-checked under it."
impact: fix
---

registerRepo does an unlocked load-modify-write of ~/.abcd/history/index.json (loadHistoryIndex -> mutate -> writeHistoryIndex, apply.go), and bootstrapHistory is check-then-act. WriteFileAtomic prevents torn files but not lost updates (last rename wins), so two concurrent 'abcd ahoy install' runs from different worktrees can drop a repo registration or clobber the registry with an empty Repos list, erasing lineage/supersedes links set on explicit user confirmation. Deferred from the multi-agent bug hunt (was B30): a correct fix needs an inter-process O_EXCL/flock lock in ~/.abcd/history held across the load-mutate-write, with a re-load inside the lock and bootstrapHistory creating index.json with O_EXCL. The registerRepo sequence spans an interactive prompter.Confirm (re-founding lineage), so the lock must NOT be held across that prompt (risk of blocking other processes on user input). Low severity, self-heals on next install. Needs a dedicated change.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ severity: "minor"
category: "bug"
source: "agent-finding"
found_during: "multi-agent-bughunt"
resolution: "The capture orphan sweep and the commit write now both run under withLedgerLock, so a >60s-stalled commit can no longer land in the sweep's classify-then-unlink window; a raced commit fails loudly instead of the sweep deleting a committed file."
impact: fix
---

cleanOrphanPlaceholders (capture/alloc.go) runs locklessly from mutationPreamble at the start of every Capture/Resolve/Wontfix, while commitCapture (workflow.go) fills the reserved placeholder OUTSIDE the ledger flock. A capture stalled >60s between reservation and commit can have its just-committed issue file deleted: the sweep's Lstat passes (size 0, aged) then the stalled commit's atomic rename lands, then the sweep's os.Remove deletes the fully-committed file — after Capture returned success. Deferred from the multi-agent bug hunt (was B26): a pre-unlink re-check (orphanStillRemovable: re-Lstat + SameFile + size==0) already mitigates the common interleaving, but full elimination requires running the sweep AND the commit write under withLedgerLock (the commit write path is in workflow.go). Low severity; data-loss only under a >60s stall plus concurrency.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ called out in a **Breaking** section.
user-facing by definition). `capture wontfix` is unchanged — a non-action ships
nothing, so `wontfix/` carries no impact.

### Fixed

- **Concurrent runs can no longer drop a repo registration or delete a
just-committed issue file** (iss-101, iss-102). Two `abcd ahoy install` runs
from different worktrees shared one `~/.abcd/history/index.json`, and its
registration was an unlocked load-modify-write: atomic rename kept the file
intact but the last writer clobbered the other's update, silently erasing a
repo entry or a re-founding lineage link. The history registry now serializes
its load-modify-write behind an inter-process lock and re-loads inside it, so
concurrent registrations compose instead of overwriting; the store bootstrap
creates `index.json` with an exclusive create, so exactly one racing run seeds
it. The re-founding lineage confirmation is still asked before the lock is
taken — never across an interactive prompt — and the state it validated is
re-checked under the lock, surfacing a conflict rather than writing a link the
user approved against a stale index. Separately, the capture ledger's orphan
sweep and its commit write now take the same ledger lock, closing a window in
which a capture stalled more than sixty seconds could have its committed issue
file swept away after the capture reported success. The inter-process lock is a
single shared primitive; the capture allocator and the history registry both
route through it.

## [0.4.0] - 2026-07-22

### Breaking
Expand Down
92 changes: 73 additions & 19 deletions internal/core/ahoy/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,35 +457,89 @@ func (a *applyCtx) stepHistory() {

// registerRepo registers or refreshes this repo's entry in index.json by its
// immutable root_commit. Re-founding lineage is only set on explicit confirm.
//
// The load-modify-write runs UNDER withHistoryLock with a re-load inside the lock
// (iss-101), so two concurrent installs cannot drop a registration by racing
// last-rename-wins. The re-founding confirmation, which can block on user input,
// is asked BEFORE the lock is acquired — the lock is never held across a prompt.
// The state that prompt validated (the candidate lineage exists and is still
// linkable) is RE-CHECKED against the re-loaded index inside the lock: if a
// concurrent install changed it, the lineage link the user approved against stale
// state is refused rather than silently applied.
func (a *applyCtx) registerRepo(sha string) {
idx, err := loadHistoryIndex()
if err != nil || idx == nil {
return
}
id := a.det.RepoIdentity
if e := indexEntry(idx, sha); e != nil {
e.Name, e.Github, e.Path = id.Name, id.Github, a.cwd // refresh mutable labels
if e.Status == "" {
e.Status = "active"

// Decide, OUTSIDE the lock, whether a re-founding prompt is needed. A prompt is
// only relevant for a not-yet-registered sha with a lineage candidate; the
// refresh path (sha already present) involves no prompt.
linkLineage := false
var candSHA string
if indexEntry(idx, sha) == nil {
if cand := findRefoundingCandidate(idx, id); cand != nil {
candSHA = cand.RootCommit
linkLineage = a.prompter.Confirm("Re-founded from " + shortSHA(candSHA) + "? Link lineage?")
}
}

wrote := false
lineageConflict := false
_ = withHistoryLock(func() error {
// Re-load inside the lock: the pre-lock read may be stale after a concurrent
// install's write, and the mutation must build on the current index.
locked, lerr := loadHistoryIndex()
if lerr != nil || locked == nil {
return lerr
}
_ = writeHistoryIndex(idx)
if e := indexEntry(locked, sha); e != nil {
e.Name, e.Github, e.Path = id.Name, id.Github, a.cwd // refresh mutable labels
if e.Status == "" {
e.Status = "active"
}
if werr := writeHistoryIndex(locked); werr != nil {
return werr
}
wrote = true
return nil
}
newEntry := historyRepo{RootCommit: sha, Name: id.Name, Github: id.Github, Path: a.cwd, Status: "active"}
if linkLineage {
// Re-check the answer-relevant state under the lock: the candidate we
// prompted about must still exist by its root_commit and still be
// linkable (not already superseded by a concurrent install). A changed
// state is a conflict — skip the lineage link rather than write one the
// user approved against a now-stale index.
cand := indexEntry(locked, candSHA)
if cand == nil || cand.SupersededBy != "" {
linkLineage = false
lineageConflict = true
} else {
newEntry.Supersedes = candSHA
cand.SupersededBy = sha
cand.Status = "superseded"
}
}
locked.Repos = append(locked.Repos, newEntry)
if werr := writeHistoryIndex(locked); werr != nil {
return werr
}
wrote = true
return nil
})
if lineageConflict {
// Surface the refused link rather than silently proceed: the re-founding
// candidate the user confirmed changed under the lock, so the repo is
// registered without the lineage link they approved against stale state.
a.changes = append(a.changes,
"history lineage: link to "+shortSHA(candSHA)+" skipped (candidate changed under concurrent install)")
}
if wrote {
if root, e2 := historyRoot(); e2 == nil {
a.note(filepath.Join(root, "index.json"))
}
return
}
newEntry := historyRepo{RootCommit: sha, Name: id.Name, Github: id.Github, Path: a.cwd, Status: "active"}
if cand := findRefoundingCandidate(idx, id); cand != nil {
if a.prompter.Confirm("Re-founded from " + shortSHA(cand.RootCommit) + "? Link lineage?") {
newEntry.Supersedes = cand.RootCommit
cand.SupersededBy = sha
cand.Status = "superseded"
}
}
idx.Repos = append(idx.Repos, newEntry)
_ = writeHistoryIndex(idx)
if root, e2 := historyRoot(); e2 == nil {
a.note(filepath.Join(root, "index.json"))
}
}

Expand Down
Loading