Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .abcd/development/brief/04-surfaces/11-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,27 @@ a burst of simultaneous sub-agent completions stages in parallel instead of
queuing behind one lock whose timeout would refuse the tail of the burst. A
lock file is removed with the staged file it guards.

The subagent-stop entrypoint does not drop a transcript on a failure that a
second try would clear (iss-2610051434156079). A git call that fails once, as a
fork refused or a git killed early on a loaded machine does, reads as no root
commit, so a burst's first completions, which run before any sibling has noted
the session, once took a repository-shaped working directory for one outside
any repository and staged nothing. When the payload's working directory
carries a `.git` entry but git gave no root commit, the entrypoint asks again
for a few seconds, re-reading the session note between tries. A stage refused
because its lock was held past the lock's timeout is tried again, up to three
more times. The session-end entrypoint asks again too, within the 1.5 seconds
the host gives a session-end hook: up to two short pauses for the repository,
each begun only if it and another detection, costed at what the first took,
end within 1.2 seconds of the hook's start, and no further try of the stage. Each retry is said on stderr. A transcript read and still not
staged, with the store known, is counted in a marker beside the staging
directory, and the staged listing reports it: the entrypoint must exit 0, and
its stderr reaches only the host's debug log, so the marker is where the person
sees the loss. A working directory with no `.git` entry, whose repository no
store has seen, is not retried and has no store to mark; no queue holds such a
transcript elsewhere, since that would keep unredacted text outside every
store.

Staging is also **the outcome record the store never had.** Before it, an absent
record spanned "never ended", "ended before the store existed" and "ended and
lost" alike, and nothing could tell them apart, which is why a week of losses
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
id: dcn-2610181606223044
slug: a-sub-agent-stage-retries-a-transient
date: 2026-10-18
---
2026-10-18 — A sub-agent stage retries a transient failure and marks a final one, but queues no unresolved transcript (facilitator-on-delegation, iss-2610051434156079): the hook retries resolving the repository when the payload's cwd carries a .git entry but git gave no root commit, re-reading the session note between tries, since burst siblings write it as they resolve; it retries a stage refused by lock contention, waiting longer as the record's remedy asks; both within a bound well inside the host's hook timeout, saying each retry on stderr. A stage that still fails for a known store leaves a counted marker that abcd history staged reports, so the loss is visible to the person and not only in a debug log. No machine-level queue is added for a transcript whose repository cannot be resolved at all, because it would keep unredacted transcripts in a new location outside any store, and the retries remove the transient case the record names (grounds: the cause established on the record on 2026-10-18; gitutil.RepoShaped; the existing sub-agent gap marker's pattern; principles/loud-staging.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
schema_version: 1
id: "iss-2610181802344551"
slug: "a-context-bound-git-command-can-outlive"
severity: "minor"
category: "bug"
source: "agent-finding"
found_during: "fixing iss-2610051434156079"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/gitutil/lazyfetch.go"
remedy: "Thread the caller's context into guardObjectRead, lazyFetchGuard, readFloorConfig and the version probe when the command is context-bound, so every git the call starts ends at the caller's deadline; test with a hanging git that answers the version probe"
---

A context-bound git command can outlive its caller's deadline: isolatedGitContext runs the partial-clone guard (lazyFetchGuard) first, and the guard's own git version probe and its floor-config read run with no context, so a git that hangs holds a caller on a deadline (the status verb, the prompt hook's reads) for as long as git hangs, though the command itself is killed at the deadline. Found when TestRootCommitContextKillsGitAtTheDeadline, run alone, took 20 s against a 500 ms deadline because its hanging fake git answered the guard's probes.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ found_at: "internal/core/history/staging.go"
remedy: "Make a stage that cannot take the shared lock within its bound wait longer or queue the transcript for the next drain instead of dropping it, and have the hook say on stderr when a transcript was not staged; prove it with the 12-way burst test under -race on a deliberately slowed lock."
deferred_after: v0.13.1
deferral_reason: "Second deferral, made by the 2026-10-07 autonomous run cutting v0.13.2: that patch ships the hook-message fix and the change-guard hardening and touches no staging code. The loss has appeared only on a slow, race-instrumented macOS runner, so its cause is not yet established, and the remedy needs the staging lock reworked and proved under a deliberately slowed lock. That is an investigation of its own, owed before the next minor release; a third deferral should go to the product thinker."
resolution: "A git call that failed once on a loaded machine read as no root commit, so a burst's sub-agent hooks, running before any sibling had noted the session, took the repository for none and dropped transcripts with exit 0. gitutil.RootCommit now returns an error for a failed call and never reads one as an answer; all twenty callers handle it. Both staging hooks retry a transient failure, within their host budgets, and mark a transcript they still cannot stage in a counted marker abcd history staged reports, under --json on stderr. The 12-way burst with failing root lookups stages all 12 under -race; on main it lost exactly the 6 whose lookups failed."
impact: fix
resolved_by:
commit: "46993aee0"
---

On the v0.13.1 release PR's merge-queue run, the macOS race lane failed TestHookSubagentStopConcurrentCompletionsAllStage: 12 simultaneous subagent-stop hooks all exited 0 but only 10 transcripts were staged (internal/surface/cli/hook_subagent_stop_test.go:336). The same suite passed on the PR's own run and on every queue run earlier that day, so the loss appears only under a slow, race-instrumented host, but it is a silent loss: the hook reports success and the transcript never reaches the store. It is the defect iss-2609090828371674 resolved (the shared lock's backoff ceiling capping staging throughput), shown not to hold under load.
Expand All @@ -27,3 +31,13 @@ Deferred past v0.13.1: Second deferral, made by the 2026-10-07 autonomous run cu
## Evidence 2026-10-08

Recurred in the merge queue: PR #869 (records only) was dropped at 06:59Z when its merge group's macOS check failed TestHookSubagentStopConcurrentCompletionsAllStage, staging 11 of 12 transcripts from 12 simultaneous completions (run 37736585477). Re-enqueued; it merged. The deferral past v0.13.1 lapsed at v0.13.2, and the v0.13.3 cut's findings gate did not trip on it because it predates that anchor.

## Cause established 2026-10-18

Reproduced deterministically, so the cause no longer rests on the slow macOS runner alone. The staging lock is not it: since iss-2609090828371674 each agent stages behind its own lock, so the twelve completions of the burst never contend for one, and 25 rounds of the burst under -race here staged every transcript. Slowing all seventeen git calls the hook makes (gittest.SlowPipeGit) still stages, only slowly.

What drops a transcript is a git call that fails once rather than slowly. `gitutil.RootCommit` reads any git failure other than a timeout as "no root commit", so `resolveSubagentStore` takes the payload's cwd for a directory that is not a repository and falls back to the session note. A burst's first completions run before any sibling has written that note, so the hook prints "cannot resolve the repository for this sub-agent … staging nothing" on stderr, exits 0, and the transcript is gone. A git shim that fails the root-commit lookup once reproduces it: the hook exits 0 and nothing is staged. A loaded runner produces the same failure when a fork or a git process fails, and the burst test never printed the hooks' stderr, which is why the cause went unseen. The other path to the same silent loss is a stage that still fails, such as a lock held past its timeout, with the store known.

## Grounds

- pursued: a burst of sub-agent completions on a loaded machine stages every transcript or names each it lost; a transcript missing from the store with no marker counting it would show this wrong
7 changes: 6 additions & 1 deletion commands/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ alike. Report `session_id`, `staged_at` and `bytes`, plus `agent_id` and
text** until drained, so say so whenever the list is non-empty. The text render
also carries a note when the host has fired a sub-agent stop without handing
over a transcript path: on such a host no sub-agent transcript can be captured
at all, so an empty sub-agent corpus is the host and not the sessions. An entry
at all, so an empty sub-agent corpus is the host and not the sessions. It
carries a second note when transcripts, a session's or a sub-agent's, were
read but could not be staged, with how many, when, and the last one's reason:
those transcripts are not in the store, and the hook that lost them exited 0,
so this note is the only place the loss shows. Under `--json` both notes go to
stderr, beside the array, so read stderr as well. Tell the user how many were lost and why. An entry
past the seven-day limit renders as OVERDUE; a quarantined transcript is listed
in its own block, and is not awaiting anything — nothing will retry it.

Expand Down
12 changes: 12 additions & 0 deletions internal/core/ahoy/ahoy.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,20 @@ type RepoIdentity struct {
Name string `json:"name"`
Github string `json:"github"`
RootSHA string `json:"root_sha"`
// rootErr is why RootSHA is "" when git did not answer for a repository
// (gitutil.ErrNoRootAnswer, gitutil.ErrGitTimedOut): nil when RootSHA is
// the answer, "" included. Unexported, so the JSON envelope is unchanged.
rootErr error
}

// RootErr reports why the root commit is unknown when git did not answer for
// the repository, and nil when RootSHA is git's answer — an empty RootSHA with
// a nil RootErr is a directory outside any repository, or one with no commits.
// A caller that would key, create or refuse anything on an empty RootSHA asks
// this first, so a failed git call is never read as "no repository"
// (iss-2610051434156079).
func (id RepoIdentity) RootErr() error { return id.rootErr }

// DetectionResult is the canonical envelope. dry-run marshals exactly this
// (with Adopted=nil).
type DetectionResult struct {
Expand Down
20 changes: 20 additions & 0 deletions internal/core/ahoy/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ func install(cwd string, opts InstallOptions, p Prompter) (res InstallResult, er
p = waiting.prompter(p)
}

// git not answering is not an unmanaged repository: the repository may be
// registered, so install never ASKS to adopt it on that reading
// (iss-2610051434156079). An explicit --adopt is the person's own word and
// stands; without one, nothing is installed until git answers.
gitUnanswered := det.RepoIdentity.RootErr()
if det.RootSHA != "" {
gitUnanswered = nil
}
if gitUnanswered != nil && det.FolderKind == UnmanagedRepo && opts.Adopt == nil {
return InstallResult{}, fmt.Errorf("git gave no root commit for this repository, so abcd cannot tell whether it already manages it; run the install again once git answers, or pass --adopt to adopt it anyway: %w", gitUnanswered)
}

// Adoption gate for an unmanaged repo.
adopted := false
if det.FolderKind == UnmanagedRepo {
Expand Down Expand Up @@ -1197,6 +1209,14 @@ func (a *applyCtx) stepHistory() {
a.note(writeSessionStore, filepath.Join(root, "index.json"))
}
sha := a.det.RepoIdentity.RootSHA
if rerr := a.det.RepoIdentity.RootErr(); rerr != nil {
// git did not answer for the root commit: the repository is not
// registered or given a store this run, and says so, rather than
// being passed over as one with no commits (iss-2610051434156079).
a.refuse("could not register this repository on this machine: git gave no root commit for it (" +
fsutil.RedactRoot(errText(rerr), a.cwd, ".") + "); run `abcd ahoy install` again")
return
}
if sha == "" {
return
}
Expand Down
12 changes: 9 additions & 3 deletions internal/core/ahoy/managed.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import (
// two file reads and no subprocess; git is asked for the root commit only when
// the marker is absent AND there is an index to look it up in. Zero network,
// zero writes.
//
// It is total: a git that did not answer for the root commit reads as "not
// managed" here, which is safe only for a caller whose "not managed" is an
// omission (a board block left out, a badge not shown). A caller that refuses
// an act as unmanaged asks ManagedContext, which returns that failure.
func Managed(root string) bool {
ok, _ := ManagedContext(context.Background(), root)
return ok
Expand All @@ -34,9 +39,10 @@ func Managed(root string) bool {
// ManagedContext is Managed bound to ctx, for a caller on a deadline (the
// status verb). The one subprocess it may start — git's root commit, asked
// only when no marker block answers — runs under ctx and is killed when ctx
// ends (gitutil.RootCommitContext). A context that ended before git answered
// is returned as its own error, never as "not managed", so the caller does not
// mistake a slow git for a checkout abcd does not manage.
// ends (gitutil.RootCommitContext). A context that ended before git answered,
// and a git that gave no answer for the repository (gitutil.ErrNoRootAnswer),
// are returned as errors, never as "not managed", so the caller does not
// mistake a slow or failed git for a checkout abcd does not manage.
func ManagedContext(ctx context.Context, root string) (bool, error) {
abs, err := filepath.Abs(root)
if err != nil {
Expand Down
5 changes: 2 additions & 3 deletions internal/core/ahoy/managed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"testing"

"github.com/intentdriven/abcd/internal/gittest"
"github.com/intentdriven/abcd/internal/gitutil"
)

// committedRepo stands up a git checkout with one commit, so it has a root
Expand Down Expand Up @@ -61,7 +60,7 @@ func TestManagedAgreesWithClassify(t *testing.T) {
if _, err := bootstrapHistory(); err != nil {
t.Fatal(err)
}
sha := gitutil.RootCommit(registered)
sha := gittest.RootCommit(t, registered)
if sha == "" {
t.Fatal("the registered fixture has no root commit")
}
Expand Down Expand Up @@ -154,7 +153,7 @@ func TestManagedContextReportsAnEndedContextAsItself(t *testing.T) {
if _, err := bootstrapHistory(); err != nil {
t.Fatal(err)
}
if err := writeHistoryIndex(&historyIndex{Schema: 1, Repos: []historyRepo{{RootCommit: gitutil.RootCommit(registered), Path: registered, Status: "active"}}}); err != nil {
if err := writeHistoryIndex(&historyIndex{Schema: 1, Repos: []historyRepo{{RootCommit: gittest.RootCommit(t, registered), Path: registered, Status: "active"}}}); err != nil {
t.Fatal(err)
}
if ok, err := ManagedContext(context.Background(), registered); !ok || err != nil {
Expand Down
5 changes: 5 additions & 0 deletions internal/core/ahoy/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ func remotePrepare(cwd string) (res RemoteResult, abs string, done bool) {
if err != nil {
return refuseRemote(res, "could not classify this folder, so abcd will not act on a repository it cannot identify: "+errText(err)), "", true
}
if rerr := det.RepoIdentity.RootErr(); det.RootSHA == "" && rerr != nil {
// git not answering is not a folder outside abcd: say which it was,
// and still make no remote write (iss-2610051434156079).
return refuseRemote(res, "git gave no root commit for this repository, so abcd cannot tell whether it manages it: "+errText(rerr)), "", true
}
if det.FolderKind != ManagedRepo {
return refuseRemote(res, "this is not a repo abcd manages ("+string(det.FolderKind)+
"); a remote write is never made on a folder that did not adopt abcd (run `abcd ahoy install` first)"), "", true
Expand Down
9 changes: 7 additions & 2 deletions internal/core/ahoy/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,17 @@ func runGit(cwd string, args ...string) (string, error) {
return string(out), nil
}

// deriveIdentity builds the deterministic RepoIdentity for cwd.
// deriveIdentity builds the deterministic RepoIdentity for cwd. A git that
// did not answer for the root commit leaves RootSHA "" with the failure kept
// in RootErr, so Detect stays total while its callers can still tell "no root
// commit" from "git failed" (iss-2610051434156079).
func deriveIdentity(cwd string) RepoIdentity {
sha, err := gitutil.RootCommit(cwd)
return RepoIdentity{
Name: filepath.Base(cwd),
Github: originURL(cwd),
RootSHA: gitutil.RootCommit(cwd),
RootSHA: sha,
rootErr: err,
}
}

Expand Down
3 changes: 1 addition & 2 deletions internal/core/ahoy/store_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"testing"

"github.com/intentdriven/abcd/internal/gittest"
"github.com/intentdriven/abcd/internal/gitutil"
)

// TestRunGitIgnoresInheritedGitDir proves an inherited GIT_DIR cannot redirect
Expand Down Expand Up @@ -36,7 +35,7 @@ func TestRunGitIgnoresInheritedGitDir(t *testing.T) {
// pointing at repoB.
t.Setenv("GIT_DIR", filepath.Join(repoB, ".git"))

if got := gitutil.RootCommit(repoA); got != shaA {
if got := gittest.RootCommit(t, repoA); got != shaA {
t.Errorf("RootCommit(repoA) = %q under inherited GIT_DIR=repoB; want repoA's root %q (GIT_DIR hijacked the query)", got, shaA)
}
}
5 changes: 2 additions & 3 deletions internal/core/embargo/everystore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (

"github.com/intentdriven/abcd/internal/abcdhome"
"github.com/intentdriven/abcd/internal/gittest"
"github.com/intentdriven/abcd/internal/gitutil"
)

// ghsaPendingElsewhere is the pending id sampleState holds.
Expand Down Expand Up @@ -105,7 +104,7 @@ func TestShallowSecondCloneChecksAgainstTheFirstClonesStore(t *testing.T) {
cf.f.repo.Write("a.txt", "two\n")
cf.f.repo.Commit("c2")
clone := shallowClone(t, cf.f)
if key := filepath.Base(filepath.Dir(cf.s.Root)); gitutil.RootCommit(clone) == key {
if key := filepath.Base(filepath.Dir(cf.s.Root)); gittest.RootCommit(t, clone) == key {
t.Fatal("the shallow clone's root is the store's key, so it proves nothing")
}
wantEmbargoedIDRefused(t, cf.f.home, clone)
Expand Down Expand Up @@ -352,7 +351,7 @@ func TestNoFreePlaceAdviceFitsWhoHoldsThePlace(t *testing.T) {
other := gittest.NewRepo(t)
other.Write("README.md", "another project\n")
datedCommit(t, other, "2011-01-01T00:00:00Z", "its own root")
place := abcdhome.Path(f.home, "worktrees", gitutil.RootCommit(f.repo.Root()), StoreName)
place := abcdhome.Path(f.home, "worktrees", gittest.RootCommit(t, f.repo.Root()), StoreName)
if err := os.MkdirAll(filepath.Dir(place), 0o700); err != nil {
t.Fatal(err)
}
Expand Down
8 changes: 4 additions & 4 deletions internal/core/embargo/storeroots_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func mergeUnrelatedRoot(t *testing.T, r *gittest.Repo) string {
t.Helper()
other := datedRoot(t, r, "2090-01-01T00:00:00Z", "an unrelated root")
r.Git("merge", "-q", "-s", "ours", "--allow-unrelated-histories", "--no-edit", other)
if gitutil.RootCommit(r.Root()) != other {
if gittest.RootCommit(t, r.Root()) != other {
t.Fatal("the fixture's rev-list does not meet the merged root first")
}
return other
Expand Down Expand Up @@ -341,7 +341,7 @@ func TestStoreFoundAfterAShallowCloneDeepens(t *testing.T) {
}
holdSample(t, s)
cloneGit("-C", clone, "fetch", "-q", "--unshallow")
if gitutil.RootCommit(clone) == boundary {
if gittest.RootCommit(t, clone) == boundary {
t.Fatal("the deepened clone still has the boundary for its root")
}
wantEmbargoedIDRefused(t, src.home, clone)
Expand All @@ -359,7 +359,7 @@ func TestStoreMadeUnderAReplacedRootIsFound(t *testing.T) {
standIn := f.repo.Git("commit-tree", f.repo.Git("rev-parse", "HEAD^{tree}"), "-m", "a stand-in root")
f.repo.Git("replace", tip, standIn)
stored := f.repo.Git("-c", "core.useReplaceRefs=false", "rev-list", "--max-parents=0", "HEAD")
oldKey := gitutil.RootCommit(f.repo.Root())
oldKey := gittest.RootCommit(t, f.repo.Root())
if !gitutil.IsFullSHA(oldKey) || oldKey == stored {
t.Fatalf("the replace ref does not move the root an earlier abcd keyed on (%q, stored %q)", oldKey, stored)
}
Expand Down Expand Up @@ -469,7 +469,7 @@ func TestOrdinaryCloneKeepsItsStorePath(t *testing.T) {
if err != nil || !created {
t.Fatalf("openStore: created %v, %v", created, err)
}
if want := abcdhome.Path(f.home, "worktrees", gitutil.RootCommit(f.repo.Root()), StoreName); s.Root != want {
if want := abcdhome.Path(f.home, "worktrees", gittest.RootCommit(t, f.repo.Root()), StoreName); s.Root != want {
t.Fatalf("store at %s; want %s", s.Root, want)
}
}
Loading
Loading