Skip to content

fix(actions): Lopu repairs failed PR checks - #574

Merged
lopugit merged 1 commit into
github-actionsfrom
lopu/workflow-check-fix-33500227018
Sep 3, 2026
Merged

fix(actions): Lopu repairs failed PR checks#574
lopugit merged 1 commit into
github-actionsfrom
lopu/workflow-check-fix-33500227018

Conversation

@lopugit

@lopugit lopugit commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Lopu controller check repair

Lopu identified a failed PR check whose root cause is in the protected controller/workflow code.

Lopu control-plane fix — Graphify snapshot retention never reached the commit

File changed: .github/scripts/stage-graphify-snapshots.mjs (protected control plane)
Found while reviewing: PR #516 (developmain promotion), head f31864b2

Diagnosis

graphify-cas.mjs enforces snapshot retention correctly — it prunes superseded
snapshot trees from the worktree after activating a valid replacement. The
stager then decides what reaches the controller's commit, and it only ever
walked files that still exist:

const snapshots = filesUnder(path.join(output, "snapshots", "v1")).filter(...)
addExisting(root, [...snapshots, ...semantic], { force: true })

A pruned tree is not in that list, so it was never passed to git add and the
deletion was never staged
. Every controller-authored Graphify commit
re-tracked exactly what the router had just pruned and left the removals behind
as unstaged worktree deletions. Retention reported itself as enforced while the
bound could never land in Git.

This is why manual prunes "work once and evaporate", and it is the durable cause
of the accumulation now visible on both main and develop: 26 tracked
portable snapshots against a documented default retention of 1
, totalling
1,130,880,398 bytes across 996 files — 91% of a 1,184 MB checkout. Snapshots
are content-addressed per source fingerprint, so each merge unions both sides'
trees and nothing re-applies the bound afterwards; the branch gains roughly one
snapshot per merged branch.

Reproduction against the unfixed stager

Scratch repo, two tracked snapshot trees, one removed exactly as
pruneSnapshots removes it:

tracked snapshot files at base: 10  (2 trees)
stager output: {"snapshots":5,"semantic":0,"legacy":0,"legacySemanticRestored":0}
--- AFTER controller commit ---
pruned tree srcA still tracked : 5   <-- should be 0
unstaged deletions left behind :
 D graphify-out/snapshots/v1/srcA/artA/GRAPH_REPORT.md
 D graphify-out/snapshots/v1/srcA/artA/cost.json
 D graphify-out/snapshots/v1/srcA/artA/graph.json
 D graphify-out/snapshots/v1/srcA/artA/manifest.json
 D graphify-out/snapshots/v1/srcA/artA/snapshot.json

Fix

Stage the router's prunes explicitly, scoped to the one namespace it prunes:

  • SNAPSHOT_NAMESPACE = "graphify-out/snapshots/v1". The semantic CAS is
    append-only and deliberately never has deletions staged for it.
  • Tracked files under that namespace that no longer exist on disk are recorded
    as deletions via git update-index --force-remove. Plumbing is deliberate:
    git rm applies worktree safety heuristics, and both porcelain forms have to
    negotiate the broad graphify-out ignore rules legacy product branches still
    carry. update-index --force-remove records exactly the deletion of exactly
    those paths.
  • Fail closed on an empty namespace. If zero portable snapshot files remain
    on disk, nothing is staged. The router always activates a valid snapshot
    before pruning, so an empty namespace means a failed build, not a legitimate
    prune — staging it would delete the only graph the branch has.
  • stageGraphifySnapshots() now returns removed alongside its existing
    counters, so controller logs show the bound being applied.

Why staging deletions is safe at every call site

All three consumers restore the complete tracked tree before Graphify runs, so a
missing tracked file is always a prune and never a partial checkout:

Call site Restore before build
.github/scripts/rebase-stack/refresh-promotion-graphify.sh git reset --hard + git clean -qffdx + rm -rf graphify-out + git checkout HEAD -- graphify-out/
.github/workflows/resolve-pr-conflicts.yml git reset --hard HEAD + git clean -qffdx -e trusted/ + rm -rf graphify-out + git checkout HEAD -- graphify-out/
.github/workflows/rebase-pr-stacks.yml git reset --hard HEAD + git clean -ffdx

Removed snapshots stay recoverable from Git history, matching the documented
invariant in AI_ALL.md.

Validation

Extended --self-test (gated by control-plane-ci.ymlValidate
content-addressed Graphify snapshots
) to cover a superseded tree tracked at the
base commit, and asserted the full post-commit state rather than just the staged
diff: the pruned tree is untracked, the retained tree keeps all five portable
files, and git status --porcelain -- graphify-out is empty. Added the
fail-closed case as well.

The new coverage fails when the fix is reverted — replacing the
removeFromIndex(root, removed) call with a comment gives:

AssertionError [ERR_ASSERTION]: pruned graph.json must be staged as a deletion
  actual: false, expected: true
exit=1

Full control-plane-ci.yml verify job replicated locally against the modified
trusted checkout — every step passes:

node --check (all .github/scripts/*.mjs)                      ok
bash -n (all .github/scripts/*.sh)                            ok
git diff --check                                              ok
node --test .github/scripts/graphify-cas.test.mjs             20/20 pass
node .github/scripts/stage-graphify-snapshots.mjs --self-test OK
node --test rebase-index-fingerprint.test.mjs                 1/1 pass
node --test rebase-related-edits.test.mjs                     4/4 pass
node --test resolve-canonical-instruction-type-conflicts...   1/1 pass
node --test lopu-pr-status.test.mjs                           7/7 pass
build-all-branch.mjs --self-test                              OK
merge-main-develop-sync-pr.mjs --self-test                    OK
classify-claude-credential-failure.mjs --self-test            OK
electron-pr-release-contract.mjs                              ok

End-to-end against the same scratch-repo scenario, now with the fix:

stager output: {"snapshots":5,...,"removed":5}
--- AFTER controller commit ---
pruned tree srcA still tracked : 0   (expected 0)
retained tree srcB tracked     : 5   (expected 5)
worktree status under graphify-out: ''  (expected empty)
pruned blobs recoverable from history: 5

Follow-up (not in this change)

The existing 26-snapshot backlog on main/develop is not removed by this fix —
it only stops the next one from being created. Once this lands, a single
scripts/graphify prune in the canonical environment will collapse the tree back
to the bound and, for the first time, that removal will survive the following
controller commit. Running the prune before this fix lands would be undone by
the very next controller Graphify commit.

Source Lopu workflow run

@github-actions github-actions Bot mentioned this pull request Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

⚠️ Deterministic CI contract advisory

These examples are warning-only. They do not fail a required check or block this PR.

  • ✅ Develop-preview controller examples
  • ✅ Vercel prebuilt archive safety examples
  • ✅ Workflow control-plane examples
  • ✅ Signed Electron PR release examples
  • ⚠️ Conflict-resolver routing examples (exit 1)
Sanitized tail
    '            --effort low\n' +
    '            --max-turns 1\n' +
    '            --dangerously-skip-permissions\n' +
    '            --allowedTools ""\n' +
    '\n' +
    '      - name: Report the live credential result\n' +
    '        env:\n' +
    '          CREDENTIAL_SLOT: ${{ steps.live_probe.outputs.claude-credential-slot }}\n' +
    '          CREDENTIAL_NAME: ${{ steps.live_probe.outputs.claude-credential-name }}\n' +
    '        run: echo "Live Claude authentication succeeded with $CREDENTIAL_SLOT ($CREDENTIAL_NAME)."\n' +
    '\n' +
    '  route:\n' +
    '    if: >-\n' +
    "      inputs.promotion_source_pr == ''\n" +
    "      && inputs.promotion_plan_b64 == ''\n" +
    "      && (inputs.maintenance_operation == ''\n" +
    "          || inputs.maintenance_operation == 'manage-prs')\n" +
    "      && !(github.event_name == 'workflow_dispatch'\n" +
    "           && github.actor == 'github-actions[bot]'\n" +
    "           && github.ref_name == 'github-actions'\n" +
    "           && inputs.pr_number == ''\n" +
    "           && inputs.branch == 'lopu-internal-all-branch')\n" +
    "      && (github.event_name != 'issue_comment'\n" +
    '          || (github.event.issue.pull_request',
  expected: /anthropic-api-key-fallback:/u,
  operator: 'match',
  diff: 'simple'
}

Node.js v22.23.2
- ✅ Rebase ownership routing examples - ⚠️ Promotion-worker routing examples (exit 1)
Sanitized tail
    '#   YAML is loaded from an arbitrary PR base or head ref.\n' +
    '# - `ai-merge-paused` is a user-controlled, durable stop signal. Automation\n' +
    '#   never creates, adds, removes, or treats it as stale: when present, every\n' +
    '#   detector and worker abstains until a user removes it. This prevents base\n' +
    '#   branch movement from silently re-spending AI/Vercel/GitHub compute.\n' +
    '#\n' +
    "# graphify-out/** is never given to the AI. The repo's merge driver for\n" +
    '# graph.json is not configured in CI, so git would silently text-merge it into\n' +
    '# a mixed base+head union (the poisoned-pair state CLAUDE.md forbids). Instead,\n' +
    '# when BOTH sides touched graphify-out since the merge base, the whole\n' +
    '# directory is deterministically reset to the base side before anything else.\n' +
    '# AFTER the resolution is verified and committed, the graph is refreshed on a\n' +
    '# pristine reset tree and committed separately, so the pushed graph reflects\n' +
    '# the merged code. That ordering is required: the verify step asserts the\n' +
    '# staged graphify-out subtree still equals the base side, so refreshing\n' +
    '# earlier would fail its own check.\n' +
    '#\n' +
    '# The refresh includes LLM SEMANTIC extraction when a configured Lopu provider\n' +
    '# credential exists: `graphify extract` + `graphify cluster-only` through\n' +
    '# OPENAI_API_KEY, ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN. The configured\n' +
    '# review backend is preferred, so Terra/Sol-based Lopu review and Graphify use\n' +
    '# the same OpenAI project credential while Claude remains a fallback. Extract\n' +
    '# is manifest-incremental, and unchanged\n' +
    '# content is served from the tracked content-addressed semantic cach'... 487078 more characters,
  expected: /feature_stack_merge:[\s\S]*?if: >-\s*!cancelled\(\)\s*&& needs\.feature_stack_plan\.result == 'success'\s*&& needs\.model_config\.result == 'success'/,
  operator: 'match',
  diff: 'simple'
}

Node.js v22.23.2
- ✅ Promotion-worker behavior examples - ✅ Promotion changelog examples - ✅ Feature promoter examples - ✅ All-branch builder examples

@github-actions github-actions Bot added lopu: mergeable The PR branches can currently be merged without conflicts labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu — review of e0487dd6

Approving; no change made. This is the delivery of the stager fix I flagged as missing on #516, so I re-derived it independently rather than reading my own earlier reasoning back.

The root cause is exactly as described

pruneSnapshots() removes superseded trees with rmSync — a worktree-only delete that never touches the index. The old stager only walked files that still existed on disk, so a pruned tree was never handed to git add and its deletion was never staged. Every controller Graphify commit re-tracked exactly what the router had just pruned, and retention reported itself as enforced while the bound could never land in Git.

The numbers in the description check out

Measured independently against develop @ f31864b2:

Scope Files Bytes
graphify-out/snapshots/v1 130 (26 trees × 5 portable) 1,128,478,540
graphify-out 996 1,130,880,398
whole tree 3,162 1,242,006,530

That's 91.0% of the checkout — matching the body's "996 files / 1,130,880,398 bytes / 91% of 1,184 MB" precisely.

Blast radius is exactly the portable snapshots

trackedUnder(SNAPSHOT_NAMESPACE) returns every tracked path under the namespace, not just portable ones, so I checked what's actually tracked there before trusting it. On develop it's exactly the five portable basenames, 26 of each — no graph.html or other derived artifact is tracked under snapshots/v1. The "stale non-portable tracked file gets swept into a deletion" edge case doesn't exist in this repository.

Two other things I verified rather than assumed: removed is a new key on the returned JSON, and no caller parses these fields (checked all six call sites), so the addition is safe. And both new loops batch at 100 and no-op on an empty list, so there's no empty-argv git invocation.

The fail-closed guard on snapshots.length is the right call — pruneSnapshots always activates a valid snapshot before pruning, so an empty namespace means a failed build, not a legitimate total prune.

Validation

node .github/scripts/stage-graphify-snapshots.mjs --self-test
→ Graphify snapshot staging contract: self-test OK   (exit 0)

The extended self-test is a real regression test, not decoration: it makes the controller commit and then asserts the pruned tree is untracked, the active tree intact, and git status --porcelain -- graphify-out empty. The --no-renames note is a genuine subtlety — byte-identical fixture trees would otherwise pair each prune with the new snapshot and hide the deletion.

Sequencing note

Once this lands, the 25 excess trees need no separate cleanup commit. The next controller Graphify refresh prunes to retention and this stager now carries those deletions into the commit — the cleanup is emergent. That resolves the ordering concern I raised on #516 ("pruning now wouldn't hold"). #565 closes the other regrowth path (main → develop sync); with both landed, the bound holds on both branches.

Checks: 79 green, 0 failing. CodeQL: no open alerts on this head.

— Lopu (Claude Opus 5) 🌸

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lopu repository review

Lopu reviewed this PR against github-actions as Thingtime's principal PR and repository manager. Using Claude Opus 5.

Lopu found no justified local change to publish from this review pass.

Lopu review — PR #574 · lopu/workflow-check-fix-33500227018github-actions

Compared e0487dd6 against github-actions @ 9f7f4fa3. One file:
.github/scripts/stage-graphify-snapshots.mjs.

Check state

gh pr checks 574: 27 pass / 52 skipping / 0 fail, MERGEABLE / CLEAN. Nothing failing.
CodeQL snapshot for this head is empty; 574.json stays []. The two red advisory contracts
on this tree are pre-existing on the base, fixed by #584.

What the PR does

Staging only ever walked files that still exist, so the router's prunes never reached the
commit: every controller run re-tracked exactly the trees that had just been pruned and left the
removals as unstaged deletions. That is how a promotion branch accumulates one ~45 MB snapshot per
merged branch while retention reports itself as enforced. This records the deletions explicitly.

Findings

No defects. The three decisions that make this correct rather than merely working:

  1. Deletions are scoped to graphify-out/snapshots/v1 only. The semantic CAS is append-only and
    must never have deletions staged for it; SNAPSHOT_NAMESPACE names the one prunable namespace
    and nothing else is touched.
  2. It fails closed on an empty namespace. const removed = snapshots.length ? … : [] — zero
    remaining portable files means a failed build, not a legitimate prune, and staging that would
    delete the only graph the branch has. The self-test exercises exactly that path
    (emptied.removed === 0, empty index).
  3. update-index --force-remove rather than git rm. Justified: git rm applies worktree
    safety heuristics and both porcelain forms have to negotiate the broad graphify-out ignore rules
    legacy product branches still carry. The plumbing form records exactly the deletion of exactly
    these paths, batched 100 at a time so the argv stays bounded.

Two smaller things I checked:

  • missingFromWorktree re-throws anything that is not ENOENT, so a permissions error surfaces
    instead of being read as "pruned".
  • The self-test asserts the post-commit state as well as the staged diff
    (trackedUnder(…superseded) === 0, status --porcelain -- graphify-out empty), which is what
    proves the removals do not linger as unstaged deletions for the controller's clean-tree guard to
    trip on. --no-renames on the assertion is necessary and correctly explained: the fixture trees
    are byte-identical, so rename detection would pair each prune with the new snapshot and hide the
    deletion.

Cross-PR interaction

Merges cleanly in the whole-batch simulation. Sole file, no overlap with any other PR in the batch.
Complementary to #565, which re-applies the retention bound after a main→develop sync: #574 makes
the prune stick in the controller's own commit, #565 makes it happen at all after a merge unions
two differently-named snapshot trees.

Changes made

None.

Validation run

  • gh pr checks 574 / gh pr view 574.
  • node .github/scripts/stage-graphify-snapshots.mjs --self-testOK on this head (this is the
    exact command control-plane-ci.yml's blocking "Validate content-addressed Graphify snapshots"
    step runs).
  • workflow-control-plane-contract.mjs --self-test → OK.
  • node --check on the changed script → clean.
  • Whole-batch merge simulation → clean, and the staging self-test still passes on the merged tree.

View Lopu workflow run

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu — independent confirmation of this fix, from the other end

I hit this same defect from scratch while reviewing #516 and reproduced it before finding this PR. Posting the corroboration rather than a second fix: I reverted my duplicate, because this version fails closed on an empty snapshot namespace and mine did not.

Reproduced end-to-end, different fixture shape

Three real content-addressed snapshots committed to a fixture repo, pruneSnapshots(root, active, 1), then the stager spawned exactly as the workflows spawn it (node "$graphify_stager", cwd = repo) rather than imported:

unstaged (worktree vs index) staged (index vs HEAD)
before 10 files 0
after 0 10, all D

Same conclusion via a different route, so the diagnosis isn't fixture-shaped.

The consequence worth spelling out in the PR body

The silent bloat is the visible half. The other half is that this breaks the promotion refresh outright. refresh-promotion-graphify.sh ends in verify_derived_commit:

git diff --quiet && git diff --cached --quiet \
  || fail "Derived Graphify commit left tracked worktree or index changes."

Unstaged prune deletions trip that guard directly. And the earlier gate —

if git diff --cached --quiet; then emit refreshed nochange; exit 0; fi

— means a run whose only Graphify change is a prune reports refreshed=nochange, exits 0, and walks away leaving a dirty worktree. So on a branch carrying the backlog this isn't just "the bound doesn't stick", it's a controller step that can't complete.

One thing I checked because this fix newly introduces D entries

Both consuming workflows feed git diff --cached --name-only into a credential-needle loop:

git show ":$path" > "$blob" 2>/dev/null || true
grep -qFf "$needles" "$blob"

For a staged deletion git show ":$path" fails. I verified the redirect truncates $blob before the command runs, so a failure leaves it at 0 bytes rather than carrying the previous iteration's content forward — no stale-blob false positive, in resolve-pr-conflicts.yml, rebase-pr-stacks.yml, or refresh-promotion-graphify.sh. Confirmed against a repo with a real staged deletion and a needle that would have matched the stale content.

Live impact this unblocks

Measured on both tips at the moment:

DEFAULT_SNAPSHOT_RETENTION 1
Snapshots on main@4adda985 and develop@f31864b2 26
Tracked bytes under graphify-out/snapshots/v1 1 077 MiB (largest single snapshot 51 MB)

All 26 validate as well-formed records, so nothing is corrupt — it's ~1.05 GiB of correctly-formed, superseded output that the router keeps pruning and the commit keeps re-adding.

Merge order: this before #516. Once staging can carry a prune, the next controller Graphify run collapses the store to the bound and reclaims it in one publish; merging the promotion first just carries 26 across again.

Separately, while chasing this I found that the other half of the snapshot design is also inert — 0 of 26 snapshots record a source_tree that exists in the repo, because computeSourceFingerprint() hashes the co-located ./trusted checkout as a gitlink, so the exact cache key can never hit. Different file, fixed separately; details on #516 so this PR stays single-purpose.

🤖 Posted by Lopu, Thingtime's PR manager.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu — the fix is right, but it changes how every cross-branch Graphify merge behaves

I re-reviewed e0487dd6 against ac680d64 and reached the same verdict as the earlier passes on this PR: the diagnosis is correct, the scoping is right, and the new self-test is a real regression test — reverting removeFromIndex(root, removed) fails it with pruned graph.json must be staged as a deletion. Full control-plane-ci.yml verify replicated locally, every step green. 79 checks, 0 failing. No CodeQL alerts. I made no code changes.

What follows is one consequence I don't think has been named yet, and it's worth naming before merge.

Staging prunes makes cross-branch merges conflict

Once prunes actually land, a controller Graphify commit is a delete+add pair under graphify-out/snapshots/v1/**. Git's rename detection pairs those across branches. Before this fix deletions never reached the commit, so the shape could not occur.

Same scenario, same fixture, the repo's real .gitattributes, the workflows' restore invariant — only the stager differs:

PRE-FIX  (prunes never staged — today)   merge feature → develop : CLEAN     0 unmerged
POST-FIX (this PR)                       merge feature → develop : CONFLICT  6 unmerged

CONFLICT (rename/rename): .../<T>/GRAPH_REPORT.md renamed to .../<D>/GRAPH_REPORT.md
  in HEAD and to .../<F>/GRAPH_REPORT.md in feature.
CONFLICT (rename/rename): .../<T>/cost.json  … same shape

This is not a fixture artefact — it reproduces with fully unique per-tree files, and it reproduces at exactly the steady state this fix creates: each branch holding one snapshot (the retention bound), two branches refreshing from a common base. On the real data there's also an exact-rename floor: cost.json is byte-identical across all 26 trees on origin/main (blob 65538fa5, 216 bytes), and exact rename detection is never skipped.

It also invalidates the invariant our own .gitattributes documents — "Two branches either add distinct paths or add byte-identical content at the same path" — which quietly assumed branches only ever add.

Where that lands

Path Status
resolve-pr-conflicts.yml Covered. I replayed its deterministic graphify-out reset verbatim against a conflicted repo: git rm -rf clears the unmerged entries, and the merge completes with 0 residual conflicts, zero AI spend.
Promotions Not affected. :(exclude)graphify-out/** keeps snapshot paths out of the replayed patch entirely, with a fail-closed guard behind it.
Feature Stack merges (gh pr merge --auto --merge) Exposed — automerge is blocked while a PR is CONFLICTING.
Standing main → develop sync Exposedmerge-main-develop-sync-pr.mjs merges via PUT pulls/<n>/merge and short-circuits on mergeable === falseoutcome: "conflicting". It stops merging on its own.

So the system self-heals, but Graphify-touching merges go from clean to always conflicting, then resolved on a round-trip. That's a real shift in merge economics — worth choosing deliberately rather than meeting in production.

I deliberately did not try to fix it here. Git has no per-path rename-detection control: no .gitattributes attribute governs it, -diff doesn't stop exact renames, and merge.renames=false is repo-global and would degrade genuine source merges. The workable options are architectural — extend the deterministic graphify-out reset to every merge path, or stop tracking a per-branch content-addressed directory at all. That's your call, not something I should improvise into the protected control plane on this branch.

Two smaller things

graphify-cas.test.mjs test 20 no longer guarantees what its name says. "independent branch snapshots merge without generated-file conflicts" passes, but both branches only add — neither deletes. It cannot fail for the reason it exists. Worth extending to the delete+add shape once the above is decided; asserting today's behaviour now would just enshrine it.

The body's "Follow-up (not in this change)" is stale. It says the 26-snapshot backlog survives this fix and needs a manual scripts/graphify prune. It doesn't. Driving the real pruneSnapshots(root, active, 1) into this stager on a main-shaped fixture:

router pruned from worktree       : 25 trees, retained 1
stager output                     : {"snapshots":4,…,"removed":100}
--- AFTER ONE controller commit ---
tracked snapshot trees            : 1
deletions in that ONE commit      : 100
worktree status under graphify-out: ""        ← clean-tree guard satisfied
pruned blob recoverable from HEAD~1: blob     ← history intact

The first controller Graphify commit after this lands collapses the backlog itself — ~125 of 130 tracked files, ~1.0 GB, in a single commit. The "Sequencing note" above already called this correctly; this just confirms it against the real router. Only the description needs updating — but anyone using it to predict the next controller commit will be surprised by a ~1 GB deletion.

🤖 Posted by Lopu, Thingtime's PR manager. Claude Opus 5.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

🌸 Lopu — batch review note (conflict-batch 33508837483)

Reviewed and clean. The ⚠️ on this PR is not yoursConflict-resolver routing examples and Promotion-worker routing examples reproduce on the base
tip ac680d6 itself. #575 fixes both; nothing needed here.

The fix is right, and the scale of what it fixes is bigger than the diff
suggests.
Measured on the real trees rather than inferred:

ref tracked snapshot trees tracked bytes
main 26 1,077 MiB
develop 26 1,076 MiB
develop + #485 34 1,409 MiB

Retention is 1. This PR is one half of what stops that (the other is #565 for
the sync-merge path).

Two details I checked rather than assumed:

  • trackedUnder() runs after the force-add, so the candidate set is
    HEAD-tracked ∪ newly-staged — intersecting with "absent from the worktree" is
    exactly the pruned set. Correct ordering.
  • update-index --force-remove over git rm is the right call given the broad
    graphify-out/** ignore rules legacy branches carry.

Validation: self-test passes; with removeFromIndex(root, removed) commented
out it fails on pruned graph.json must be staged as a deletion — so the new
assertions genuinely depend on the new code.

One note, no action: the fail-closed guard keys on "≥1 portable file exists", which
is right for a failed build but wouldn't catch a partially materialized checkout.
I checked every call site — rebase-conflict-round, resolve-pr-conflicts,
rebase-pr-stacks, refresh-promotion-graphify — and none stages against a
sparse product tree, so there's no live exposure. Worth remembering if a sparse
product checkout ever appears.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Lopu — repository review

The bug you're describing is real and I measured it on develop today:

  • DEFAULT_SNAPSHOT_RETENTION is 1
  • graphify-out/snapshots/v1 carries 34 snapshot trees

A 34× overshoot is exactly the "retention reports itself as enforced while nothing lands" failure mode, and it independently corroborates #565. Between you, the two ends of the leak are covered: this PR makes prunes reach the commit, #565 re-applies the bound to a merge result that unioned two already-bounded sets.

On the implementation

update-index --force-remove over git rm is the right instrument and the comment explains why — git rm applies worktree safety heuristics and both porcelain forms have to negotiate the broad graphify-out ignore rules legacy branches still carry.

The fail-closed guard is the detail that matters most. Computing removed only when snapshots.length is non-zero means an empty namespace stages nothing. The router always activates a valid snapshot before pruning, so zero remaining portable files means a failed build, not a legitimate prune — and staging that would have deleted the only graph the branch has. Getting this backwards would have been a data-loss bug. You got it right and tested it.

--no-renames in the test deserves a mention too: the fixture's trees are byte-identical, so rename detection would have paired each prune with the new snapshot and hidden the deletion. That's an easy way to write a test that silently proves nothing, and you avoided it deliberately. The git status --porcelain -- graphify-out assertion after committing is a good addition — it pins that deletions don't survive as unstaged changes for the controller's clean-tree guard to trip on later.

Validation I ran:

Check Result
stage-graphify-snapshots.mjs --self-test pass
Mutation: keep the new test, revert the implementation fails — the coverage genuinely pins the fix
workflow-control-plane-contract --self-test pass

The mutation test is the one I care about: it proves the new assertions detect the original bug rather than passing alongside it.

The two red contract advisories here are pre-existing on ac680d64 (verified against a pristine base), non-blocking, and fixed by #575.

Verdict: approve. No changes needed. Worth merging alongside #565.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu review — approve; this is one of the higher-value PRs in the batch

The bug is exactly as described — staging only ever walked files that still exist, so the router's prunes never reached the commit: every controller run re-tracked the trees it had just pruned and left the removals as unstaged deletions. Retention reported itself as enforced while nothing was removed in Git.

I measured what that has cost:

Ref tracked graphify-out files bytes
develop @ 7b6418bd 1 014 1 411 MB
a current PR head in this batch 1 001 (27 distinct snapshot fingerprints) 1 128 MB

DEFAULT_SNAPSHOT_RETENTION is 1. Several graph.json blobs are 40–50 MB each. And this has observable CI consequences right now: GitHub answers HTTP 422 "this diff is taking too long to generate" on diffs this size (that's what took the repository-wide conflict scan down on #576), and #557's aggregate CodeQL check aged out to timed_out.

So #574 (prunes reach the commit) and #565 (re-apply the bound after a sync merge) are the two halves of the repository-health fix, and they're worth more than their diff size suggests.

Implementation notes I checked and agree with:

  • update-index --force-remove over git rm — plumbing avoids the worktree heuristics and the broad graphify-out/snapshots/ ignore rules legacy product branches still carry.
  • missingFromWorktree rethrows anything that isn't ENOENT, so a permissions error surfaces instead of being read as "pruned".
  • The fail-closed guard is the most important line here. An empty namespace stages nothing, because the router always activates before pruning — so zero portable files means a failed build, and staging that would delete the only graph the branch has. Covered explicitly by the self-test.
  • --no-renames in the verification diff is a real subtlety: the fixture's trees are byte-identical, so rename detection would pair each prune with the new snapshot and hide the deletion the test exists to prove.
  • The final git status --porcelain -- graphify-out assertion is what actually protects the controller's clean-tree guard.

Validation: node .github/scripts/stage-graphify-snapshots.mjs --self-test → OK, and clean when applied alongside #565/#573/#575/#576/#577/#579 with the full battery green.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🌸 Lopu — real bug, and the self-test asserts the property that would have caught it

Merge order. The contracts run in a non-blocking advisory job, so green checks here don't certify them. Two are already red at github-actions tip 9f7f4fa3 and #575 is the only open PR that fixes either — land it first. This PR merges cleanly regardless; the seven-PR combined merge is conflict-free with all fourteen contracts passing.

The diagnosis matches what's visible in the repository. Staging only ever walked files that still exist, so every controller run re-tracked exactly the trees the router had just pruned and left the removals unstaged — retention reporting itself as enforced while a promotion branch accumulated one snapshot per merged branch. You can see the shape of it in this same review batch: the open product PRs each carry a fresh ~1.33M-line graph.json tree.

The things I checked rather than took on trust:

  • Fail-closed guard. const removed = snapshots.length ? … : [] means an empty portable set stages nothing. The router always activates a valid snapshot before pruning, so zero remaining files means a failed build, not a legitimate prune — and staging that would delete the only graph the branch has. The self-test exercises this path directly.
  • Namespace scoping. SNAPSHOT_NAMESPACE is graphify-out/snapshots/v1 only. The semantic CAS is append-only and stays excluded, so a deletion can never be staged for it.
  • Plumbing over porcelain is justified, not just terse. git rm applies worktree safety heuristics, and both porcelain forms have to negotiate the broad graphify-out ignore rules legacy product branches still carry. update-index --force-remove records exactly these deletions, batched 100 at a time so argv stays bounded.
  • missingFromWorktree error handling. Only ENOENT counts as missing; any other lstat error rethrows rather than being silently read as a prune. Easy to get wrong, and it's right.

The self-test assertion I'd single out is the last one: after committing, it checks git status --porcelain -- graphify-out is empty. That's the property — no unstaged deletion left behind for the controller's clean-tree guard to trip on — rather than the mechanism, and it's the assertion that would have caught the original bug. The --no-renames flag matters too: the fixture's trees are byte-identical, so rename detection would pair each prune with the new snapshot and hide the deletion entirely.

Complementary to #565, which bounds the same accumulation from the merge side (a main→develop sync unions two differently-named content-addressed trees and nothing re-applied the bound to the result). The two are independent and both are needed; neither subsumes the other.

No changes made. stage-graphify-snapshots.mjs --self-test passes at this head and merged into the tip.

Lopu · automated repository review · 0 open CodeQL alerts on e0487dd6

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🤖 Lopu review — ✅ validated, no changes requested. Flagging the severity of what this fixes, because it's easy to under-read.

$ node .github/scripts/stage-graphify-snapshots.mjs --self-test
Graphify snapshot staging contract: self-test OK

This is a silent unbounded-growth bug, not a cosmetic staging nit. Staging
only ever walked files that still exist, so every controller run re-tracked
exactly the trees that had just been pruned and left the removals as unstaged
deletions — a promotion branch accumulates one snapshot per merged branch while
retention reports itself as enforced. A bound that reports success while the
repo grows without limit is the worst version of this failure.

Three details I checked and agree with:

  • update-index --force-remove over git rm — justified: git rm applies
    worktree safety heuristics and both porcelain forms have to negotiate the broad
    graphify-out ignore rules legacy product branches still carry. Batched at 100
    paths, so a large prune won't blow the argument limit.
  • Scoped to graphify-out/snapshots/v1 via the new SNAPSHOT_NAMESPACE
    the semantic CAS is append-only and must never have deletions staged for it.
  • Fails closed on an empty namespace (snapshots.length ? … : []) — this is
    the single most important line in the diff. Without it, one broken build would
    stage the deletion of the only graph the branch has.

The self-test extension is adversarial rather than confirmatory: it builds a
superseded tree, prunes it, and asserts the deletion is staged, that the tree
is clean afterwards (so the controller's clean-tree guard doesn't trip), and
that the empty-namespace case stages nothing. --no-renames is necessary and
correctly explained — the fixture's trees are byte-identical, so rename detection
would pair each prune with the new snapshot and hide the very deletion the test
exists to prove.

This is the strongest single change in the current review batch.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🌸 Lopu — correct, and the self-test is stronger than the code it covers

No changes needed. The diagnosis is precise: stageGraphifySnapshots only ever walked files that still exist, so the router's prunes never reached the commit — every controller run re-tracked the trees it had just pruned and left the removals unstaged. Retention reported itself as enforced while a promotion branch accumulated one ~45 MB tree per merged branch graph.

Things I checked rather than assumed:

  • git update-index --force-remove over git rm is justified — porcelain applies worktree heuristics and has to negotiate the broad graphify-out/ ignore rules legacy product branches still carry. Batched at 100, matching restoreTrackedFromHead in the same file.
  • missingFromWorktree rethrows anything that isn't ENOENT, so a permissions or I/O fault fails the run instead of being silently read as "pruned". That's the direction that matters.
  • The empty-namespace guard is genuinely fail-closed. I cross-read graphify-cas.mjs to confirm the router always activates a valid snapshot before pruning — so zero remaining portable files means a failed build, and staging those deletions would remove the only graph the branch has.
  • Scoping to graphify-out/snapshots/v1 is right: it's the only namespace the router prunes, and the semantic CAS is append-only, so no deletion can ever be staged for it.

The self-test earns particular credit. --no-renames isn't decoration — the fixture's trees are byte-identical, so rename detection would pair each prune with the new snapshot and hide the deletion, and the test would pass vacuously without it. And asserting git status --porcelain -- graphify-out is empty after the commit proves the fix also stops leaving unstaged deletions for the controller's clean-tree guard to trip on, which is a second failure mode the code change quietly fixes.

Coordination note. #565 re-applies the same retention bound at the main→develop sync merge, where content-addressed trees union rather than conflict. Complementary, not overlapping: this makes prunes reach the commit at all, #565 re-bounds a merge result nothing had re-bounded. No file overlap; verified they merge cleanly together and alongside #584/#573/#577/#580/#579, all 15 control-plane contracts green on the combined tree.

stage-graphify-snapshots.mjs --self-test passes on this head. The two failing advisories fail identically on github-actions@9f7f4fa3 — inherited; #584 fixes both.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Lopu repository review — batch review of 16 open PRs.

This one has a visible symptom in this very repository. Staging only ever walked files that still exist, so the router's prunes never reached the commit — every controller run re-tracked exactly the trees that had just been pruned and left the removals unstaged. That matches what I see across this batch: every develop-based PR carries its own full ~1.3M-line graphify-out/snapshots/v1/** tree, while retention reports itself as enforced.

Three things I checked specifically:

  • Plumbing over porcelain is justified. git rm applies worktree safety heuristics, and both porcelain forms have to negotiate the broad graphify-out/ ignore rules legacy product branches still carry — the same rules addExisting(..., { force: true }) already works around. update-index --force-remove records exactly the intended deletion.
  • The fail-closed guard is correctly placed. removed is only computed when snapshots.length is non-zero, so an empty namespace — a failed build, not a legitimate prune — stages nothing rather than deleting the only graph the branch has. The self-test exercises this directly.
  • Namespace scoping is right. SNAPSHOT_NAMESPACE is graphify-out/snapshots/v1; the append-only semantic CAS lives at graphify-out/cache/semantic-cas/v1. I verified the two are disjoint in the source rather than trusting the comment.

The self-test additions are the strong part — committing the result and then asserting git status --porcelain -- graphify-out is empty is what proves the removals don't survive as unstaged deletions for the controller's clean-tree guard to trip on. --no-renames is correctly used, since the fixture's trees are byte-identical and rename detection would otherwise pair each prune with the new snapshot and hide the deletion.

Worth landing together with #565, which fixes the other half — retention being undone by the main → develop sync merge.

Validation: stage-graphify-snapshots.mjs --self-test OK (this one runs in the blocking verify job, not the advisory lane), and the full blocking suite passes. Only PR touching this file; merges cleanly with all eight siblings.

No changes made.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Lopu review — no defects found. This fixes a genuinely self-concealing bug and I'd land it.

The failure mode is the interesting part: staging only ever walked files that still existed, so the router's prunes never reached the commit — every controller run re-tracked exactly the trees it had just pruned and left the removals as unstaged deletions. That's how a promotion branch accumulates one snapshot per merged branch while retention reports itself as enforced. A leak that reports success is worth more than the diff suggests.

Two things I want to call out as correct:

  • The fail-closed guard is the important half. removed is computed only when snapshots.length is non-zero, so an empty namespace — which means a failed build, not a legitimate total prune — can't stage away the only graph the branch has. The self-test pins it directly.
  • update-index --force-remove over git rm is justified, and for the stated reason: git rm applies worktree safety heuristics and both porcelain forms have to negotiate the broad graphify-out ignore rules legacy product branches carry. The plumbing form records exactly the intended deletion and nothing else.

--no-renames in the new assertion is also load-bearing, not decorative — the fixture's trees are byte-identical, so rename detection would otherwise pair each prune with the new snapshot and hide the deletion under test.

Deletion scope stays correctly narrowed to graphify-out/snapshots/v1, leaving the append-only semantic CAS untouched.

Validation: stage-graphify-snapshots.mjs --self-test passes, including the new prune and fail-closed cases · workflow-control-plane-contract ✅ · merges clean onto the tip ✅

The two contract failures visible in this worktree are pre-existing on the github-actions tip and are fixed by #584.

Recommended batch order for the nine lopu/workflow-check-fix PRs, validated end-to-end in a scratch merge: #565#574#573#577#580#584#579#588, with #575 closed as superseded by #584. That sequence merges clean except for one mechanical conflict between #579 and #588 (contract file only), and with it resolved all five control-plane contracts pass — versus two red on the tip today.

Posted by Lopu, Thingtime's PR manager.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Lopu · reading this diff, and where it sits in the controller queue

Two-dot diff artifact. GitHub's "Files changed" view shows .github/scripts/graphify-cas.mjs and graphify-cas.test.mjs as deleted. They are not. This branch forked at ac680d6, before those files landed on github-actions, and GitHub renders a two-dot diff. The real change is:

git diff $(git merge-base github-actions HEAD)...HEAD

Same artifact appears on #573, #574, #575, #577, #579 and #580 — worth knowing before anyone reviews the set.

On the change. Cleanest of the nine: one file, one bug, a fail-closed guard, and a test that catches the regression class. Prunes never reached the commit because staging only walked files that still exist — so every controller run re-tracked exactly the trees just pruned and left the removals unstaged, and retention reported itself as enforced while the branch grew a snapshot per merged branch. update-index --force-remove over git rm is right (plumbing, no worktree heuristics, negotiates the broad graphify-out ignore rules legacy branches carry). The most important line is snapshots.length ? … : []: an empty namespace means a failed build, not a legitimate prune, and staging that would delete the only graph the branch has. It has its own test. Both of its suites run in the blocking verify job, not the advisory lane — I ran them here: self-test OK, graphify-cas.test.mjs 20/20. Pairs with #565, which is what makes retention hold across a main→develop sync.

Merge order. Nine lopu/workflow-check-fix-* PRs are open against github-actions. I simulated the cumulative integration and ran the control-plane contract after every step: 565 → 573 → 574 → 575 → 577 → 579 → 580 all merge clean and stay green. This PR is inside that clean prefix. The conflicts start at #584 (it overlaps #575 — see my note there) and between #579 and #588.

Pre-existing advisory failures. resolve-pr-conflicts-routing-contract.mjs (at resolve-pr-conflicts.yml:733) and promotion-worker-routing-contract.mjs fail in this worktree — and fail identically on github-actions @ 9f7f4fa. They are not from this PR; #584 fixes both.

@lopugit
lopugit merged commit 429667d into github-actions Sep 3, 2026
79 checks passed
@github-actions github-actions Bot removed the lopu: mergeable The PR branches can currently be merged without conflicts label Sep 3, 2026
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