Files
msd-core/docs/research/3875-ack-sweep-automation-failure.md
Jakub Zych 12ee75a509
Some checks failed
Tests / PR mergeability (push) Successful in 1m37s
Tests / Base branch health (push) Successful in 11s
Tests / Detect test scope (push) Successful in 17s
Tests / lint-tests (push) Failing after 2m16s
Tests / plugin-validate (push) Successful in 1m6s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 25s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 19s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 8s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
chore: point MSD at git.golem15.com/golem15/msd-core
The fork lives on the golem15 Gitea forge, not GitHub. Package identity now
parses either host and derives in-place raw URLs for Gitea; the identity-drift
lint accepts the new host; README drops GitHub-only badges and the npm
quickstart in favour of the checkout installer.
2026-10-06 10:41:56 +02:00

24 KiB

Why the ack-fragment sweep (#3875) is not "fully automatic": PR #3927 case study

Status: research note — grounded in the repo's own source, workflow YAML, ADRs, and live gh/GitHub API data for PR #3927, pulled 2026-08-27.

Summary

ack-fragment-sweep.yml (cron 30 */6 * * *) correctly detects and plans the deletion of spent tests/emitted-drift-acks/*.json fragments, and correctly opens a PR. It does not, and structurally cannot, get that PR merged unattended:

  1. Its hardcoded PR title (ack-fragment-sweep.yml:266) never satisfies the repo's own title-validator regex (scripts/release-notes/conventional-title.cjs:28,84-93) — a self-inflicted, deterministic failure on every run.
  2. Its freeform PR body fails the typed-template check, but that check only warns (not fails) because the sweep authenticates as a MEMBER account, not because the body is compliant (scripts/pr-template-policy.cjs:167-240).
  3. Its diff — one or more tests/emitted-drift-acks/*.json deletions — matches no rule in scripts/ci-test-scope.cjs's RULES table, so classify() falls through the #408 empty-match fallback and selects the 'unit' suite sentinel for the unsharded, 15-minute-capped "targeted/fast-signal" matrix lane (scripts/ci-test-scope.cjs:520-563, .github/workflows/test.yml:155,166-172). That lane then runs effectively the whole 827-file corpus and is killed by its own job timeout — verified directly in the run log (below). This is not flaky; it will recur on every future sweep PR whose diff is exactly the kind the sweep workflow always produces.
  4. There is no auto-merge path for this PR anywhere in the workflow set. It sat open 2h38m and was merged by a human maintainer (trek-e, MEMBER) via a merge that landed while validate-title and Required tests were still red — an override, not an automatic pass.

A. The model itself

A1. What an emitted-drift-ack fragment is, and its lifecycle

Read in full: scripts/lint-emitted-drift-ack.cjs (937 lines) and tests/emitted-drift-acks/README.md.

  • Purpose (scripts/lint-emitted-drift-ack.cjs:1-31): the differential-attribution gate (tests/emitted-attribution.test.cjs, ADR-2719) requires every emitted-artifact byte or size delta between next and a PR's HEAD to be attributable to that PR's own diff. When it legitimately cannot be (a converter change, a deliberate size grow), the escape hatch is a committed acknowledgment fragment under tests/emitted-drift-acks/<name>.json, one file per PR (#2914), never the legacy single tests/emitted-drift-ack.json.
  • Two key spaces (tests/emitted-drift-acks/README.md:20-23): an unattributable hash ripple is keyed on the emitted path; workflow/agent-file growth is keyed on the bare filename.
  • Creation: the differential check's own failure output prints the exact fragment name/shape to paste (CONTRIBUTING.md:1074-1076, docs/TESTING-SUITES.md:139-145) — a human authors it as part of the PR that needs it.
  • "Spent" (scripts/lint-emitted-drift-ack.cjs:304-322, function assertNoAllSpentFragments): every entry's prose in the fragment is compared, byte-for-byte after invisible-character/whitespace normalization (ackProse, line 261), against the copy of the same fragment at the pre-push tip of next (readFragmentAtRef, line 538). If every surviving entry matches its base-ref copy, the whole fragment is "all-spent" — its ripple is now baked into next, so it can no longer clear anything, yet it still owns its path keys (a second source declaring the same key is a hard duplicate-key error, lines 872-885).
  • Red on next: the guard-no-ack-on-next job (.github/workflows/test.yml:865-920) runs on every push to next and calls --guard-next --base-ref <before> --defer-to-open-prs. It fails when (a) the legacy tests/emitted-drift-ack.json is present at all (assertAbsentOnNext, line 449), or (b) any fragment is fully spent and not held by an open PR that still touches it (#3842 deferral, line 363).
  • Remedy: git rm the fragment. Since #3875, ack-fragment-sweep.yml automates exactly this git rm, computed via --sweep-plan (lines 815-833), which asks the guard for the identical set it reasoned about rather than re-deriving it.

A2. Governing ADRs/docs (verbatim quotes)

  • ADR-2719 (docs/adr/2719-emitted-artifact-attribution.md:1-11): "Emitted-artifact attribution — replace the committed parity fixtures with a computed conservation law." Accepted 2026-07-27, supersedes ADR-2264 §2-4.
  • CONTEXT.md:625 (RULESET.EMITTED_ATTRIBUTION): "...#2914 replaced the single shared ack file with per-PR fragments...exactly the shape .changeset/ already uses for the identical 'every PR rewrites one shared document' conflict problem...#3078 REVERSED that [premise] — fragments do not share a FILE but they DO share a PATH KEY SPACE...#3875 automated the REMEDY that alert asks for, because detection without an executable remedy is what actually failed: #3823 shipped the guard together with a static 45-fragment sweep computed at its own branch point, #3809's fragment merged to next while it was in flight, and the guard reddened on its own merge commit and stayed red for 24 consecutive pushes over two days."
  • CONTRIBUTING.md:1086-1094: "every PR needing an acknowledgment used to rewrite tests/emitted-drift-ack.json's paths map wholesale — a single shared mutable file every such PR touches guarantees a merge conflict between any two of them (5 of 6 conflicting PRs in one open queue collided on this file and nothing else)."
  • tests/emitted-drift-acks/README.md:37-41: "The sweep is automated because the manual remedy could not keep up. The guard evaluates at MERGE time; a hand-authored git rm is fixed at BRANCH time...#3823 lost exactly that race to #3809 on its own merge commit and left next red for 24 consecutive pushes."

A3. Every CI surface referencing the ack model

  • .github/workflows/test.yml:865-920 — guard-no-ack-on-next job (push-to-next trigger; the "next-lane guard"). Also referenced at test.yml:842-864 (comment block explaining scope/limits) and test.yml:127 (the lint-tests step runs node scripts/lint-emitted-drift-ack.cjs as part of lint:ci, see CONTRIBUTING.md cross-ref and the raw log line captured in this session: lint-tests step at 2026-08-27T09:38:55Z runs the full chained lint including node scripts/lint-emitted-drift-ack.cjs).
  • .github/workflows/ack-fragment-sweep.yml — the entire file (237 lines); scheduled sweep, sweep job, calls --guard-next --sweep-plan --defer-to-open-prs (line 90-92) and --guard-next --sweep-plan unheld variant (line 115-116).
  • scripts/lint-emitted-drift-ack.cjs — the validator/guard/sweep-plan CLI itself (937 lines; exports validateAckText, assertAbsentOnNext, assertNoAllSpentFragments, runGuardNext, main, fetchOpenPrTouchedAckPaths, etc.).
  • tests/helpers/emitted-diff.cjs and tests/helpers/emitted-runtime.cjs — the "ships"-side duplicate of the same constants/logic (ACK_INVISIBLE, MAX_ACK_FRAGMENTS, RESERVED_ACK_KEYS, readAckFileAtRef), held to parity by tests/emitted-attribution.test.cjs (per scripts/lint-emitted-drift-ack.cjs:19-23,58-63,77-81,94-99).
  • No eslint-rules/* or .claude/hooks/* file references emitted-drift/ack fragments (grepped both directories; zero hits).
  • docs/TESTING-SUITES.md:130-160 and CONTRIBUTING.md:1055-1124 — the human-facing "Editing shipped content" / TESTING-SUITES how-to sections.

A4. Design-history issues (via gh issue view)

  • #2789 — root-caused why merging an ack reddened next: the ack set was the one input read absolutely (working tree only) in an otherwise base-relative differential machine, so a spent ack (already at base) still looked live to staleAcks. Fixed by scoping every ack to the diff that introduced it.
  • #3078 — proved #2914's premise wrong. #2914 exempted the fragment directory from the next-lane guard on the theory that independently-named fragments "cannot conflict with any other PR." #3078 measured 45 fragments owning 403 paths on next and showed fragments share a path key space even without sharing a file, so a spent fragment left in place still walls off the next PR that grows the same path. Extended guard-no-ack-on-next to cover fragments (implemented in PR #3823, "Closes #3078," merge commit a84f75630).
  • #3842 — the #3078 sweep, once it started deleting fragments, handed three outside-contributor open PRs (#3330, #3774, #3648) a modify/delete git conflict apiece, each on the exact fragment it was the PR's only conflicting path. Added the --defer-to-open-prs hold (fetchOpenPrTouchedAckPaths).
  • #3823 (a PR, not a separate issue — gh issue view 3823 resolves to the PR body) — implemented the fragment half of guard-no-ack-on-next, closing #3078.
  • #3875 — root-caused the 24-consecutive-push red streak: #3823 computed its sweep as a static list at branch time; #3809's fragment merged to next while #3823 was in flight and was therefore never in that list. Proposed the scheduled sweeper (ack-fragment-sweep.yml) that recomputes the plan from the guard itself, on a timer, "honouring the existing --defer-to-open-prs hold." This issue is exactly what PR #3927 is discharging.

A5. Blast radius of removing the model entirely

Files/jobs/tests that would need to change if emitted-drift-ack fragments were removed outright:

  • scripts/lint-emitted-drift-ack.cjs — deleted (or gutted to a no-op).
  • .github/workflows/test.yml:865-920 (guard-no-ack-on-next job) and its two checkout/fetch steps (test.yml:875-895) — deleted.
  • .github/workflows/ack-fragment-sweep.yml — deleted entirely (237 lines).
  • tests/emitted-drift-acks/ directory and its README.md — deleted.
  • tests/helpers/emitted-diff.cjs, tests/helpers/emitted-runtime.cjs — the ack-reading halves (readAckFileAtRef, mergeAckSources, readAckSources, readAckSourcesAtRef, isSpent, staleAcks, spentAcks) removed; tests/emitted-attribution.test.cjs's differential check would then need a different escape hatch for legitimate unattributable ripples, or none at all (making every ripple a hard failure).
  • tests/emitted-attribution.test.cjs — its ack-consuming assertions and the "#2914 migration pins" mentioned in tests/emitted-drift-acks/README.md:64-69 removed.
  • tests/pr-template-policy.test.cjs, scripts/pr-template-policy.cjs — unaffected directly, but the sweep PR's body text (which cites the ack model) would need rewriting if the workflow itself survives in some other form.
  • CONTEXT.md's RULESET.EMITTED_ATTRIBUTION entry (CONTEXT.md:625) and the ### Emitted Artifact Provenance section it cross-references — rewritten.
  • CONTRIBUTING.md:1055-1124 ("Editing shipped content" ack sections) — rewritten.
  • docs/TESTING-SUITES.md:130-160 (the "workflow or agent grew" how-to) — rewritten.
  • docs/adr/2719-emitted-artifact-attribution.md — would need a superseding ADR documenting the new escape hatch (or its removal), per this repo's own convention that architectural reversals get a new ADR rather than a silent edit.
  • Any lint/test referencing tests/emitted-drift-ack.json or tests/emitted-drift-acks/ by path (grep -rl across .github/workflows/, scripts/, tests/ returns test.yml, ack-fragment-sweep.yml, lint-emitted-drift-ack.cjs, docs/TESTING-SUITES.md, CONTRIBUTING.md, CONTEXT.md, and the tests/emitted-drift-acks/README.md itself — all of the above).
  • Issues #2789, #2914, #3078, #3842, #3875, #3823 would all become historical/moot context requiring a note that the model they describe no longer exists.

B. Why #3927 hung — one root cause per red signal

B6. validate-title — the sweep's own hardcoded title cannot pass its own gate

The sweep hardcodes: --title "chore: sweep spent ack fragments from next (${SHORT_SHA})" (ack-fragment-sweep.yml:266), producing the literal title chore: sweep spent ack fragments from next (929e02cb) for PR #3927.

The gate (.github/workflows/pr-title-validator.yml) delegates to evaluatePrTitle() in scripts/release-notes/conventional-title.cjs:81-97, which requires a header matching HEADER_RE = /^([a-z]+)(\([^)]*\))?(!)?:/i (line 28) and a scope group containing #\d+ (ISSUE_REF_IN_SCOPE_RE, line 31; checked at line 92).

Against chore: sweep spent ack fragments from next (929e02cb): HEADER_RE matches with type="chore" and scope-group undefined (there is no (...) immediately after chore — the colon follows directly). Since !scope is true, evaluatePrTitle returns { valid: false, reason: 'missing-issue-ref' } (line 92-93) — confirmed as the actual CI failure (gh pr checks 3927: validate-title fail 8s).

Two independent defects in the hardcoded string, either one sufficient to fail: (1) the parenthetical (${SHORT_SHA}) sits at the end of the title, not immediately after the type, so it is never read as the "scope" group at all; (2) even if repositioned, a short git SHA (929e02cb) contains no #, so it would still fail ISSUE_REF_IN_SCOPE_RE.

A title that would pass: chore(#3875): sweep spent ack fragments from next (929e02cb) — matches the repo's own worked example in conventional-title.cjs:71 (enhance(#1549): add PR-title validator) and correctly attributes the sweep to the issue it closes toward (#3875), which the current PR body already does in prose but the title does not.

B7. Pull request template format — passed, but only because the account is trusted, not because the body complies

Contrary to the initial framing, this check passed (gh pr checks 3927: Pull request template format pass 12s). The mechanism, read in full from scripts/pr-template-policy.cjs:

  • The sweep's PR body (ack-fragment-sweep.yml:243-261) is freeform prose — no ## Fix PR / ## Enhancement PR / ## Feature PR heading, none of the required headings in TEMPLATES (pr-template-policy.cjs:55-97).
  • The changed-files carve-out does not apply: tests/emitted-drift-acks/*.json is not in TOOLING_PATH_ALLOWLIST (pr-template-policy.cjs:24-43), so allPathsAreTooling returns false.
  • matchingTemplate() finds no heading match → template: null → falls to reason = 'PR body does not match the fix, enhancement, or feature template.', valid: false (pr-template-policy.cjs:217-223).
  • The consequence is gated on author trust, not template compliance (pr-template-policy.cjs:226-229): action = trusted ? 'warn' : 'close'. TRUSTED_AUTHOR_ASSOCIATIONS includes MEMBER (line 6-11). The PR's actual author association is MEMBER (gh api repos/golem15/msd-core/pulls/3927 --jq '.author_association' → MEMBER), because the workflow authenticates gh pr create with MSD_BOT_PR_TOKEN — a personal-access token belonging to a real maintainer account (trek-e), not a GitHub App/bot identity. So action: warn: the msd-pr-template-policy bot comment fires (core.warning, pr-template-format.yml's "Warn trusted contributor" step), but the workflow only core.setFaileds on action == 'close' — never reached. The template body is objectively wrong; the check is objectively green.

B8. Required tests / test (ubuntu-latest, 24) — the fast-signal lane ran the WHOLE suite and hit its own 15-minute cap

gh run view 33059472019 --repo golem15/msd-core --log-failed and the per-job API (jobs/98474440239, jobs/98478252101) show:

  • test (ubuntu-latest, 24) — the unsharded matrix entry (.github/workflows/test.yml:198-200: {os: ubuntu-latest, node-version: 24, scope: targeted}, no shard key, hence the bare job name per the name: template at test.yml:130) — started 09:38:38Z, its "Run scoped tests" step ran from 09:39:10Z to 09:53:51Z (14m41s), and the whole job was marked cancelled at 09:53:56Z, total 15m18s — exactly the job's timeout-minutes: 15 cap (test.yml:155).
  • The raw log for that job shows run-tests: suite="all" files=827 at 09:39:11Z, and the job reached only chunk 13/14 before being killed (chunk 13/14 — 61 files at 09:52:37Z) — i.e. it was running virtually the entire test corpus (827 files) in one unsharded process, not a narrow "targeted/fast-signal" subset. The three actually-sharded ubuntu scope: full jobs (which exist precisely to spread this same corpus across 3 runners, per the #2952/#3057 comments at test.yml:141-164) all finished successfully in 6-9 minutes each.
  • Root cause, traced through the scope classifier: scripts/ci-test-scope.cjs's RULES array (lines 71-324) has no entry matching tests/emitted-drift-acks/*.json. In classify() (lines 483-591), the changed path starts with tests/ so productOrPipelineChanged = true (line 502), but it is not a .test.cjs file so it is not added to targeted/windows (line 520 requires endsWith('.test.cjs')), and no RULES[].match fires for it. The #408 fallback then fires: if (codeChanged && targetedTests.length === 0) targetedTests.push('unit') (lines 561-563). scripts/ci-prepare-test-scope.cjs's resolveSelection() (lines 49-71) passes that non-empty ['unit'] list through verbatim to .ci-selected-tests.txt, and 'unit' is a SUITE_SENTINEL (ci-prepare-test-scope.cjs:22) that run-tests.cjs resolves live to (effectively) the whole non-integration/security/install/slow corpus — 827 files, matching the observed log.
  • Required tests (.github/workflows/test.yml:702-703) then reads TEST_RESULT: cancelled (its own job log, captured verbatim above) and fails with ##[error]test matrix did not pass — a mechanical propagation of the canceled test job, not an independent finding.
  • This is deterministic, not flaky. Every sweep PR's diff is, by construction, one or more deletions under tests/emitted-drift-acks/ — a path class the scope-classification table has never had a rule for. Any future run of ack-fragment-sweep.yml will hit the identical fallback and the identical 15-minute cliff, for the same reason PR #3094 (referenced in test.yml:151-153) hit it on the scope: windows lane before that lane was sharded — except the scope: targeted lane was never sharded because nothing was expected to widen it to "all."

B9. How #3927 was actually merged despite red required checks

gh api repos/golem15/msd-core/pulls/3927 --jq '.merged_by.login, .merge_commit_sha, .author_association' → trek-e, ad6abc896..., MEMBER. The PR was opened 09:38:15Z and merged 12:16:49Z (2h38m later) by the same account, trek-e (a human maintainer, is_bot: false per gh pr list ... --json mergedBy). The timeline (gh api .../issues/3927/timeline) shows only review_requested → two bot commented events → merged/closed/head_ref_deleted, all attributed to trek-e or github-actions[bot]; no re-run or re-triggered check event appears between open and merge. gh pr checks 3927 (queried post-merge) still reports validate-title fail and Required tests fail as the latest, final state of those checks — they were never turned green. The merge therefore landed with required checks still red: a maintainer override (admin merge / branch-protection bypass), not an automatic pass triggered by the automation.

B10. No auto-merge path exists for the sweep PR

ack-fragment-sweep.yml ends at gh pr create + gh pr edit --add-label automation --add-label no-changelog (lines 263-280) — no gh pr merge, no enableAutoMerge call, nothing. Grepping every workflow for automerge/auto-merge finds only auto-backmerge.yml (an unrelated main→next release-engineering workflow, gated on push: branches: [main], using -s ours merges — nothing about ack fragments) and a comment in release.yml:729 about release→main PRs. pr-mergeable-preflight.yml is a reusable merge-conflict early-exit gate shared by eight caller workflows; it applies no label and performs no merge (pr-mergeable-preflight.yml:1-40). There is no maintainer-facing auto-merge wired to ack-fragment-sweep.yml's PRs at all — every one requires a human to notice it, review it, and merge it by hand (or override), exactly as ack-fragment-sweep.yml:14-15's own top-of-file comment states: "a human still approves the deletion."

B11. MSD_BOT_PR_TOKEN fallback warning did NOT fire — ruled out as a cause

gh run view 33059445125 --repo golem15/msd-core --log (the sweep workflow's own run) shows the "Open the sweep PR" step's env block: HAS_BOT_TOKEN: 1. The step's guard is if [ -z "${HAS_BOT_TOKEN:-}" ]; then echo '::warning::MSD_BOT_PR_TOKEN is unset...'; fi (ack-fragment-sweep.yml:191-193) — with HAS_BOT_TOKEN=1 this branch is skipped, and indeed no such warning line appears anywhere in the run log. MSD_BOT_PR_TOKEN was configured and used. This is corroborated independently by B7/B9: the PR's author_association is MEMBER (not the unauthenticated-fallback shape) and the title/template required checks did run and report real verdicts (validate-title, Pull request template format both executed) — under the GITHUB_TOKEN fallback the workflow's own comment (ack-fragment-sweep.yml:189) states "no required checks will run on it," which is not what happened. The fallback-token hypothesis is not the cause of this hang. (Git commit authorship on the branch itself still reads github-actions[bot] per git config user.name at ack-fragment-sweep.yml:204-205 — a separate, cosmetic identity from the gh API actor, which is trek-e via the PAT.)

C. Track record — is #3927 representative or a one-off?

gh pr list --repo golem15/msd-core --search "head:chore/ack-sweep" --state all --limit 30 --json number,title,state,createdAt,mergedAt,mergedBy returns exactly one PR: #3927 itself (opened 09:38:15Z, merged 12:16:49Z, mergedBy: trek-e). There is no prior sweep PR to compare against — this is the automation's first (and so far only) production run that produced a non-empty plan and opened a PR. It cannot yet be called "systematically failing" by volume, but the failure mechanism identified in B8 is a property of the scope-classification table and the sweep's title-generation code, not of this specific run's data — so it will reproduce on the next sweep PR with high confidence, absent a fix to either ack-fragment-sweep.yml's title format or scripts/ci-test-scope.cjs's RULES table (or both).

Open questions

  • Should scripts/ci-test-scope.cjs's RULES gain an explicit entry for tests/emitted-drift-acks/ (mapping to a narrow, cheap test list — e.g. tests/emitted-attribution.test.cjs, tests/lint-emitted-drift-ack.cjs's own test file if any) so a fragment-only diff no longer falls through the #408 "unit" fallback onto the unsharded lane? This is the most direct fix for B8, and does not require sharding the scope: targeted lane.
  • Should ack-fragment-sweep.yml:266 be changed to --title "chore(#3875): sweep ${COUNT} spent ack fragment(s) from next (${SHORT_SHA})" (or similarly scoped) to satisfy conventional-title.cjs outright? This is a one-line fix for B6.
  • Should the sweep PR body be reshaped to match one of the three typed templates (likely "Fix" or a new "chore" carve-out) so Pull request template format passes on its merits rather than on the author's trust level — closing the latent gap where an untrusted-token run of this same workflow (if MSD_BOT_PR_TOKEN ever lapses to a non-MEMBER identity) would hit action: close instead of warn?
  • Given B10/B11, should the workflow (or a separate follow-up automation) add a bounded auto-merge step once its own required checks are green, so a correctly-titled, correctly-scoped sweep PR does not still require a human to notice and merge it by hand every six hours?