Skip to content

fix: never read a failed git call as no repository, and never lose a staged transcript silently - #1104

Merged
REPPL merged 11 commits into
mainfrom
fix/history-stage-burst
Oct 18, 2026
Merged

REPPL merged 11 commits into
mainfrom
fix/history-stage-burst

Conversation

@REPPL

@REPPL REPPL commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

A burst of twelve simultaneous sub-agent completions on a loaded CI runner staged only ten or eleven transcripts, while every hook exited 0. The record had been deferred twice because its cause was not known. This change establishes the cause and fixes it at the primitive.

Cause. It was not the staging lock: each agent has had its own lock since an earlier fix. A git call that fails once (a refused fork, an early-killed git) was read as "no root commit" by gitutil.RootCommit. The sub-agent hook then took a real repository for none. A burst's first completions run before any sibling has noted the session, so the hook staged nothing and exited 0, saying so only on a stderr no one reads. A git shim failing the root lookup for six of twelve hooks loses exactly six on main.

The fix, in three layers:

  1. The root-commit lookup. RootCommit now returns (string, error). An empty answer means only a missing directory, no repository (git says so, or no .git entry at or above the path, as written or resolved), or a repository with no commits. Any other failure is an error. All twenty callers handle it: they propagate it, or keep a safe fallback and name the failure, and never take the "no repository" path that keys, creates, migrates or refuses. A related cache is fixed too: a git version probe that could not run was being kept as "git too old" for the rest of the process.
  2. The hooks retry. The sub-agent and session-end hooks retry a transient failure, and a stage refused by a held lock, within their hosts' budgets. Session end has 1.5 seconds, so its retries are bounded by time from the hook's start.
  3. A loss is visible. A transcript read and still not staged is counted in a marker that abcd history staged reports; under --json it goes to stderr.

New tests cover each layer, and each one was watched failing on the code before it. These include a twelve-way burst with failing root lookups, a staging lock held past its timeout, a session-end retry, a symlinked path, a bare repository, and a version probe that fails once. The hook and marker tests passed three times under -race. An Opus and Sonnet review pair reviewed the change twice, and every finding is folded in.

The guard's own probes not honouring a caller's deadline are captured separately, as a smaller follow-up.

Resolves: iss-2610051434156079
Refs: iss-2610181802344551

Assisted-by: Claude:claude-opus-5-5

REPPL added 11 commits October 18, 2026 17:15
Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
A git call that fails once, as a refused fork or an early-killed git does
on a loaded machine, reads as no root commit. A burst's first sub-agent
completions run before any sibling has noted the session, so the hook took
a repository-shaped cwd for one outside any repository, staged nothing and
exited 0, saying so only on a stderr no one reads.

When the cwd carries a .git entry but git gave no root commit, the hook now
asks again for a few seconds, re-reading the session note between tries. A
stage refused by a lock held past its timeout is tried again. A transcript
read and still not staged, with the store known, is counted in a marker
that history staged reports. The burst test prints each hook's stderr when
it fails, so a loss names its cause.

Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
gitutil.RootCommit returned "" for every git failure but a timeout, so a
refused fork or an early-killed git on a loaded machine read as "no root
commit" at every caller. It now returns (string, error). ("", nil) means
only a missing directory, no repository at or above it, or a repository
with no commits, which `rev-parse --verify -q HEAD` confirms. Any other
failure is ErrNoRootAnswer or ErrGitTimedOut.

Every caller now gives the error a deliberate answer. Where it returns
errors, it propagates. Where it is total, it keeps its safe fallback and
says git did not answer. Nowhere does the error take the empty path with
consequences: keying, creating or migrating a store, adopting a
transcript, or refusing as "not a repository". ahoy's identity carries the
failure apart from a definite "no root". The session-end hook now retries
it, as the sub-agent hook does, and both mark a transcript they still
cannot stage. The stage-miss marker covers both.

Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
The host gives a SessionEnd hook 1.5 seconds unless it asks for more, so
the sub-agent hook's three and a half seconds of resolution waits and its
stage retries would have been cut off there, leaving neither the stage nor
the marker. Session end now pauses twice, briefly, for the repository and
does not retry the stage.

Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
…s deadline

Refs: iss-2610181802344551
Assisted-by: Claude:claude-opus-5-5
The partial-clone guard probed git's version once per process and kept
the answer even when the probe failed, so a git that could not run once
on a loaded machine read as a git too old for the rest of the process,
refusing every object read in a partial clone. A failed probe is no longer
kept; a line git answered with still is. The deadline test now warms the
probe with the real git first, so it no longer depends on test order.

Refs: iss-2610051434156079
Refs: iss-2610181802344551
Assisted-by: Claude:claude-opus-5-5
The root-commit lookup took a path with no .git entry on it for no
repository without asking git, so a directory reached through a symlink,
or a bare repository, read as none. git is now asked first. A failure is
read as "not a repository" only when git says so in its pinned C locale,
or when neither the path nor its resolved form carries a .git entry.

history staged --json printed only the array, so an agent told to report a
lost transcript saw none. Both notes now also go to stderr there, and the
text render no longer says every session is stored above a loss.

Install no longer asks to adopt a repository git did not answer for; an
explicit --adopt still stands. ahoy's status names the failure instead of
"unmanaged". The hooks do not retry the partial-clone floor, which answers
the same every time. A marker that no longer parses is replaced with a
reason saying earlier misses were counted there.

Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
…s unread

A git version probe that could not run is not a git below the floor.
Refusing it as ErrLazyFetchFloor made both hooks skip their retries, so a
transcript in a partial clone was lost to one failed fork after all. The
read is still refused, now as ErrGitVersionUnread, which a later try may
clear. An explicit --refuse-adopt with git unanswered reports the abort it
asked for.

Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
…y time

A directory with no .git entry, as written or resolved, is in no
repository whatever git did, so git missing from PATH no longer turns a
plain folder into one git did not answer for. Session end's retries are
now bounded by time from the hook's start, each costed at what the first
detection took, since detection takes most of a second on the loaded
machine where git fails. The stage-miss marker keeps, through later misses,
when an unreadable earlier marker was replaced.

Refs: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
Resolves: iss-2610051434156079
Assisted-by: Claude:claude-opus-5-5
@REPPL
REPPL enabled auto-merge October 18, 2026 19:28
@REPPL
REPPL added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 43a6f81 Oct 18, 2026
15 checks passed
@REPPL
REPPL deleted the fix/history-stage-burst branch October 18, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant