fix(backends): prevent duplicate task ids in prune archives - #1
Merged
JasonUnifyAccount merged 2 commits intoOct 1, 2026
Merged
JasonUnifyAccount merged 2 commits into
JasonUnifyAccount merged 2 commits into
Conversation
prune appended every surplus task to the archive without checking whether that task id was already archived. Re-pruning an id (for example after the same id re-entered the backlog and was completed again) wrote a second record for the same identity, which breaks archive readers that expect one record per id. prune now reads the task ids the archive already holds (any task bullet form, since a task can be pruned from any section) and appends only the surplus tasks whose id is not yet archived, also collapsing an id that repeats within one batch. Which tasks are selected and removed from the backlog is unchanged. Choice: skip, keeping the existing archived record, rather than replace it. - The archive stays strictly append-only, so the existing restore point (truncate back to the pre-prune size) remains a complete rollback; a replace would need a whole-file rewrite with its own snapshot/rollback. - An archived record can be annotated in place after archival (e.g. a resolution written into its body); replacing it with the re-pruned copy would silently drop that. - The re-pruned copy is not reliably the better one: a re-added entry can carry a stripped title or a different repo field, while the first record is the one written when the task was originally completed. - Readers that already resolved the id keep seeing the same record. The cost is that the re-pruned copy's content is not archived; its id already is. When every surplus id is already archived, nothing is appended (no empty "## Archived" block) and no restore point is taken, so a failed backlog write cannot truncate or otherwise touch the archive. The archive format, grammar parse/render, and the byte-exact round-trip are unchanged; the grammar only gains an exported taskBulletId helper that reuses the existing bullet patterns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Task
Pruning a completed task into the archive appends it blindly, with no check for whether that
task id is already archived. Re-pruning an id therefore creates a duplicate archive identity,
which breaks the consumer that reads the archive and, in the Firstmate fleet, disables every
captain-decision closure until someone removes the duplicate by hand.
Exact site, confirmed in this fork at 0.2.6
src/backends/markdown.ts,prune()at line 1161:const archivedLines = surplusEntries.flatMap((entry) =>
entry.raw.length > 0 ? entry.raw : renderTaskLines(entry.task),
);
...
if (options.archive) {
this.assertUnchanged(loaded);
archiveRestorePoint = this.captureArchiveRestorePoint();
this.appendArchive(archivedLines);
}
appendArchiveis an unconditional append. Nothing consults the archive for an existing entrywith the same task id, so the archive can hold the same identity twice.
NOT already fixed upstream
Installed version is 0.2.4; this fork is at 0.2.6. The intervening fixes are unrelated -
canonical Forgejo pull request URLs (kunchenguid#36), failed public follow-ups (kunchenguid#67), generated skill
deferring to live CLI guidance (kunchenguid#48), and faster standalone version queries (kunchenguid#34). The source
read above is from 0.2.6, so the defect is current.
Evidence that it recurs, measured 2026-10-01
In the Firstmate fleet,
bin/fm-decision-hold-archive.pyraises on any duplicate archiveidentity, which disables closing ANY captain decision. Closing three decisions produced three
new duplicates: the first close appended two already-archived identities and blocked the next
close, those were removed by hand, the next close appended another. Earlier hand repairs
cleared nine entries on 2026-09-28 and thirteen on 2026-10-01; both regenerated. The loop is
that closing a decision prunes an entry, which duplicates an existing one, which blocks the
next closure.
Required outcome
prunemust not create a second archive entry for a task id the archive already holds. Decidebetween skipping that id and replacing the existing entry, and state which you chose and why in
the commit message - the archived entry may legitimately differ (the later one can carry a
different repo field or a stripped title), so silently keeping the older or the newer one is a
real choice, not a formality. Preserve the existing atomicity: the archive restore point and
the persist/rollback path must still hold, and a prune that archives nothing must not corrupt
or truncate the archive.
Out of scope
The stripped title on some archived entries - where the title renders identical to the id -
originates in the consuming fleet's own backlog section handling, not here. Do not try to
reconstruct titles. Do not change the archive file format, the grammar, or the byte-exact
round-trip guarantee the package advertises. Do not alter which tasks are selected for pruning.
Upstream
This repository is a fork of the third-party MIT-licensed kunchenguid/tasks-axi, taken
2026-10-01 because we have read but not write access upstream.
upstreamis configured as aremote. Keep the change small and self-contained so it can be offered upstream as a pull
request afterwards; that offer is a separate decision, not part of this task.
Verification
Write the failing test first and confirm it fails for the intended reason. Required coverage:
pruning a task id that the archive already holds leaves exactly one entry for that id; pruning
a fresh id still appends normally; the byte-exact round-trip and the rollback-on-persist-failure
behaviour are unchanged. Use the repository's existing test command and conventions.
Implementation decisions (worker, for review context)
commit message): the archive stays strictly append-only, so the existing size-based restore
point (truncate back to pre-prune size) remains a complete rollback, whereas replace would
need a whole-file rewrite with its own snapshot/rollback; an archived record can be
annotated in place after archival (the fleet's consumer has a replace-body operation that
writes a resolution into an archived record's body), and replacing it would silently drop
that; the re-pruned copy is not reliably better (a re-added entry can carry a stripped title
or different repo field); readers that already resolved the id keep the same record. The
accepted cost is that the re-pruned copy's own content is not archived - its id already is.
PruneResult(
archivedcount andids) still reports every task that left the backlog, includingones whose id was already archived. No CLI output shape change.
- [x] id -,- [ ] id -,- **id** -) becauseprune --state queued|in_flightarchives those formstoo. It reuses the grammar's existing bullet regexes via a new exported pure helper
taskBulletIdinmarkdown-grammar.ts; parse/render behaviour is untouched.parser does not reject) is archived once (first in file order).
## Archivedblock) and no restore point is taken, so a failed backlog write cannot touch the archive.
options.archiveis true.one-record-per-id rule.
fm-ensure-agents-md.shreported a conflict because the upstream repo ships both AGENTS.mdand a CLAUDE.md that only contains
@AGENTS.md; left as-is to keep the change upstreamable.before the fix because the already-archived id was appended a second time; 2 new rollback
guards (pre-existing archive restored byte-exact; nothing-to-archive + persist failure
leaves archive untouched) pass before and after, proving rollback is unchanged.
Pre-authorized validation corrections
Contract:
fm-preauthorized-corrections.v1.The captain's approval of this task pre-authorizes the independent validation path to classify and apply only these bounded correction classes without coordinator escalation:
contract-conformance: the smallest implementation or configuration correction objectively required by an explicit accepted requirement or acceptance criterion, including the smallest correction that restores a convention already stated by the accepted contract or by the tracked documentation of the code being changed, or repairs a regression of a known, previously-fixed defect class.regression-coverage: the smallest executable test correction needed to prove or preserve explicitly accepted behavior.documentation-alignment: the smallest documentation or final-diff evidence correction needed to describe the accepted behavior and current validated head accurately.A correction may span one or more classes only when every actual effect remains within their union and no exclusion below applies.
The accepted contract means the finalized task instructions carried by this brief plus explicit later clarifications in their current accepted form.
The tracked documentation of the code being changed means documentation of that code committed in the same repository and versioned alongside it, read in the state it held before the change being classified, so only a statement already committed in that documentation before that change states a convention for this purpose.
A statement that the change being classified itself introduces or amends is absent from that pre-change state, so a correction resting only on such a statement is contract-expanding and a change never qualifies its own correction by writing the convention it then cites; a convention the documentation already stated before that change remains available even when the change edits that same document elsewhere.
An outside guide, a dependency vendor manual, and a standard the project merely follows are not that documentation either, and a correction resting only on such a document is contract-expanding unless the accepted contract itself requires conformance to it.
A correction that adds a new guarantee, subsystem, abstraction, or monitoring requirement remains contract-expanding even when it appears beneficial or addresses the same causal area.
A correction is excluded from this pre-authorization when its actual effect is destructive, irreversible, genuinely security-sensitive, ambiguous, or contract-expanding.
Independent validation must classify an eligible finding as
auto-fixand any excluded or uncertain finding asask-user; labels, severity, technical difficulty, and repeated causal themes do not change those boundaries.The implementation worker never classifies its own finding.
It may continue without Firstmate only from the independent validation path's current
auto-fixclassification and must route everyask-userfinding through the keyed decision path these instructions require.What Changed
Risk Assessment
✅ Low: The change is confined to archive id filtering, preserves the existing prune selection and rollback path, and includes behavioral coverage for duplicate ids, fresh appends, and failed writes.
Testing
After correcting an initial Vitest flag invocation, the focused tests passed. Five isolated CLI checks passed and produced the attached archive transcript; temporary worktree data was removed and the worktree is clean. Persist-failure rollback and pure grammar round-trip were covered by focused tests but have no live CLI fault or no-op render interface.
Evidence: CLI prune and done transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
corepack pnpm install --frozen-lockfilecorepack pnpm exec vitest run test/backends/markdown.test.ts test/backends/markdown-grammar.test.ts test/commands/state.test.ts -t 'prune|byte-exact round-trip|does not duplicate an archived id when a re-added task is completed|restores a pre-existing archive byte-exact|leaves the archive untouched when nothing new is archived'Rancorepack pnpm exec tsx bin/tasks-axi.tsagainst isolated backlog and archive files for transcript cases 1–5; checked JSON results, persisted records, and byte-identical archives withcmp.✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.