DECISIONS-LOG.md was append-only in ONE file and every agent appended to its
tail, so any two concurrent branches collided on the same last hunk. Measured
against the live Gitea today: four of the five open PRs were mergeable=false,
and `git merge-tree` named DECISIONS-LOG.md as the ONLY conflicting path in
all four.
#22 fix/crafty-oauth2-proxy-signin CONFLICT: DECISIONS-LOG.md
#20 feat/conformance-job-template CONFLICT: DECISIONS-LOG.md
#19 feat/conformance-trigger-kubeconfig CONFLICT: DECISIONS-LOG.md
#18 feat/conformance-runner-image CONFLICT: DECISIONS-LOG.md
#17 feat/ci-is-the-approver clean — it wrote no handoff
Nothing was wrong with any of those changes. The log's storage format
serialised work that was otherwise parallel, and the cost grew with every
concurrent branch (N branches -> N-1 rebases, each invalidating the gate run
before it). The agents work in isolated worktrees precisely so they do not
block each other; this one file undid that.
So: stop sharing a file. Handoffs are now one-per-file under decisions/,
named YYYY-MM-DD-<slug>.md. Disjoint paths have nothing to merge. There is
deliberately no index file (a shared mutable file is the thing that just
failed) and no counter (two agents would race for the same number) — the
directory listing is the index.
DECISIONS-LOG.md is frozen as the pre-2026-09-29 archive, its SHA-256 pinned
in decisions/ARCHIVE.sha256, and a new gate stage rejects any change to it.
That is a guard and not a paragraph because the old habit is written into the
runbooks and into every agent that has read the log, and getting it wrong does
not fail: it produces a clean-looking commit that re-creates the conflict on
OTHER branches, later, misattributed.
ci/decisions-log.sh new stage: archive pinned; every entry named with
a real date, carrying date/branch/title frontmatter
that agrees with its filename, and all three of
CLAUDE.md §7's sections with a real body under each.
Exit 0 or 1 only — no skip path by construction, so
no fail-open surface on a guard that closes one.
ci/gate.sh runs it; fail and (impossible) skip both BLOCK.
No REQUIRE_DECISIONS_LOG knob: a guard with an off
switch is not a guard.
ci/tests/decisions-log.test.sh 22 assertions, incl. the one that matters —
an append to the archive is rejected, and the
message names the file to write instead.
ci/tests/gate-decision.test.sh +3 assertions for the new wiring, AND a
pre-existing bug: the stub list never included
gitea-settings, so run_stage saw exit 127 and
recorded a FAILED stage in every case. 15 of 47
assertions failed on main for that reason alone —
the suite was measuring its own missing stub.
Now 50/50.
CLAUDE.md §7 rewritten; §2b widened to the new path.
ci/Makefile, ci/README.md, .gitea/workflows/README.md — stage named where
the other stages are named.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
83 lines
5.0 KiB
Markdown
83 lines
5.0 KiB
Markdown
---
|
|
date: 2026-09-29
|
|
branch: chore/decisions-log-conflict-free
|
|
title: The handoff log was serialising every parallel branch — it is a directory now
|
|
---
|
|
|
|
## What changed
|
|
|
|
`DECISIONS-LOG.md` is frozen and the handoff log is now a directory, `decisions/`, with one
|
|
file per entry (`YYYY-MM-DD-<slug>.md`). Concurrent branches write disjoint paths, so there
|
|
is nothing for git to merge.
|
|
|
|
The problem was not any particular change. CLAUDE.md §7 told every agent to append a handoff,
|
|
and appending always landed on the *same last hunk* of one 5,228-line file. Two branches that
|
|
both did it could not auto-merge; N branches cost N-1 rebases, and each rebase invalidated the
|
|
gate run before it. The agents work in isolated worktrees precisely so they don't block each
|
|
other, and this one file undid that.
|
|
|
|
- `decisions/README.md` — the convention, the template, and how to read the log. No index file
|
|
and no sequence number, deliberately: an index is a shared mutable file (the thing that just
|
|
failed) and a counter has two agents racing for the same number. The directory listing is the
|
|
index.
|
|
- `DECISIONS-LOG.md` — a "FROZEN ARCHIVE" header prepended; content otherwise untouched. Its
|
|
SHA-256 is pinned in `decisions/ARCHIVE.sha256`.
|
|
- `ci/decisions-log.sh` — a new gate stage. Asserts the archive matches its pin, and that every
|
|
entry is named `YYYY-MM-DD-<slug>.md` with a real date, carries `date`/`branch`/`title`
|
|
frontmatter agreeing with its filename, and has all three of §7's sections with a real body
|
|
under each. Exit 0 or 1 only; it has no skip path by construction, because it reads only files
|
|
in the tree under test and needs no tool and no cluster.
|
|
- `ci/gate.sh` — runs the stage; both `fail` and (impossible) `skip` set `BLOCK`. There is no
|
|
`REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard.
|
|
- `ci/tests/decisions-log.test.sh` — 22 assertions, new.
|
|
- `ci/tests/gate-decision.test.sh` — 3 new assertions for the stage's wiring, **plus a
|
|
pre-existing bug fixed**: its stub list never included `gitea-settings`, so that stage's
|
|
script was absent from the stub CI dir, `run_stage` recorded exit 127 as a FAILED stage in
|
|
every case, and 15 of 47 assertions failed on `main`. The suite was measuring its own missing
|
|
stub rather than the decision logic.
|
|
- `CLAUDE.md` §7 rewritten; §1's "stale runbook wording" caveat extended to the log.
|
|
- `ci/Makefile`, `ci/README.md`, `.gitea/workflows/README.md` — the stage named where stages are
|
|
named.
|
|
|
|
A guard rather than a paragraph, because the old habit is written into the runbooks and into
|
|
every agent that has read the log, and getting it wrong does not fail: it produces a clean-looking
|
|
commit that re-creates the conflict on *other people's* branches, later, misattributed.
|
|
|
|
## What I verified
|
|
|
|
Measured against the live Gitea (`GET /api/v1/repos/Home-Network/home-network-k3s-impl/pulls`,
|
|
2026-09-29), not inferred from the tree:
|
|
|
|
#22 fix/crafty-oauth2-proxy-signin mergeable=False
|
|
#20 feat/conformance-job-template mergeable=False
|
|
#19 feat/conformance-trigger-kubeconfig mergeable=False
|
|
#18 feat/conformance-runner-image mergeable=False
|
|
#17 feat/ci-is-the-approver mergeable=True
|
|
|
|
`git merge-tree --write-tree origin/main origin/<branch>` for each named
|
|
`CONFLICT (content): DECISIONS-LOG.md` and **no other conflicting path** in all four. #17 is
|
|
clean and touched no log entry. So the log's storage format was the whole of the blockage.
|
|
|
|
Tests: `ci/tests/decisions-log.test.sh` **PASS (22 assertions)**, including the one that
|
|
matters — an append to `DECISIONS-LOG.md` exits non-zero and the message names both the frozen
|
|
archive and the file to write instead. `ci/tests/gate-decision.test.sh` **PASS (50 assertions)**,
|
|
up from **FAIL (15 failed, 32 passed)** on `main` before the stub fix.
|
|
|
|
Gate: see the run quoted in the PR description.
|
|
|
|
## What I did NOT verify
|
|
|
|
- **Nothing was applied and nothing was merged.** No `kubectl`, no `flux reconcile`. This change
|
|
touches only CI and documentation; it has no cluster-visible effect at all.
|
|
- **L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), as always. Irrelevant to this
|
|
change's content but stated so the gate result is not over-read.
|
|
- **I have not watched a Gitea PR check run this stage.** The stage is proven by its own unit
|
|
test and by the gate locally; that the `incluster-dind` runner executes it is unobserved here.
|
|
A skipped Gitea job still reports `success`, so read the job log, not the tick.
|
|
- **The four hung PRs are not yet fixed by this commit.** They still carry their
|
|
`DECISIONS-LOG.md` appends and will still conflict until each is rebased onto this change with
|
|
its append converted to a `decisions/` entry. That is follow-up work, in flight separately.
|
|
- **`runbooks/` and older entries still say "append to DECISIONS-LOG.md"** in places. I did not
|
|
sweep them. The gate now rejects the action, so the stale wording misleads but cannot cause
|
|
the damage; it joins the existing tracked work on stale `git push origin main` wording.
|