Skip to content

fix(backends): prevent duplicate task ids in prune archives - #1

Merged
JasonUnifyAccount merged 2 commits into
mainfrom
fm/tasks-axi-prune-appends-duplicate-archive-entry-v1
Oct 1, 2026
Merged

JasonUnifyAccount merged 2 commits into
mainfrom
fm/tasks-axi-prune-appends-duplicate-archive-entry-v1

Conversation

@JasonUnifyAccount

Copy link
Copy Markdown
Collaborator

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);
}

appendArchive is an unconditional append. Nothing consults the archive for an existing entry
with 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.py raises on any duplicate archive
identity, 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

prune must not create a second archive entry for a task id the archive already holds. Decide
between 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. upstream is configured as a
remote. 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)

  • Chosen: SKIP, keeping the existing archived record, not replace. Rationale (also in the
    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.
  • Which tasks are selected and removed from the backlog is unchanged; PruneResult
    (archived count and ids) still reports every task that left the backlog, including
    ones whose id was already archived. No CLI output shape change.
  • Archive id detection scans column-0 task bullets in every state form (- [x] id - ,
    - [ ] id - , - **id** - ) because prune --state queued|in_flight archives those forms
    too. It reuses the grammar's existing bullet regexes via a new exported pure helper
    taskBulletId in markdown-grammar.ts; parse/render behaviour is untouched.
  • An id repeated within one prune batch (hand-edited backlog with duplicate ids, which the
    parser does not reject) is archived once (first in file order).
  • 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 touch the archive.
  • The archive is only read when options.archive is true.
  • Docs: AGENTS.md D4 bullet and README archive paragraph each gained one sentence stating the
    one-record-per-id rule.
  • fm-ensure-agents-md.sh reported a conflict because the upstream repo ships both AGENTS.md
    and a CLAUDE.md that only contains @AGENTS.md; left as-is to keep the change upstreamable.
  • Tests: 5 new tests (4 backend, 1 CLI workflow archive -> re-add -> done auto-prune) failed
    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-fix and any excluded or uncertain finding as ask-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-fix classification and must route every ask-user finding through the keyed decision path these instructions require.

What Changed

  • Pruning skips task ids already in the archive, preserving the existing record while removing the task from the backlog. Repeated ids in one prune batch are archived once.
  • Archive id detection covers done, queued, and in-flight task bullets. When there is nothing new to archive, pruning leaves the archive untouched.
  • Adds backend and CLI regression coverage for duplicate ids and rollback, and documents the archive rule.

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.

  • Live validation: ✅ go - 3 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Prune removes completed tasks while keeping the first archived record for an existing ID, appending a fresh ID, and reporting both removed tasks ✅ pass live CLI prune and done transcript, cases 1–2
Completing a re-added decision with automatic pruning preserves its earlier archived title and resolution ✅ pass live CLI prune and done transcript, case 3
Prune recognizes archived queued and in-flight bullet forms and archives a repeated ID only once per batch ✅ pass live CLI prune and done transcript, cases 4–5
A failed backlog persist restores a prior archive and leaves an archive untouched when nothing was appended ⏸️ untested no The CLI has no controlled post-append persist-failure hook. Providing one would allow this failure path to be driven live; focused backend tests exercised it.
An untouched markdown backlog survives parse and render byte-exactly ⏸️ untested no The CLI has no public parse-and-render-without-mutation operation. A public harness for that operation would allow a live check; focused grammar tests exercised it.
Evidence: CLI prune and done transcript
CASE 1: existing annotated record plus fresh task
{
  "ok": true,
  "action": "prune",
  "state": "done",
  "kept": 1,
  "archived": 2,
  "ids": [
    "fresh-b2",
    "old-c3"
  ]
}
# Archive

## Archived 2026-06-01
- [x] old-c3 - original title (repo: original) (closed 2026-06-01)
  captain resolution retained

## Archived 2026-10-01
- [x] fresh-b2 - newly completed (closed 2026-07-02)
CASE 1 assertions: PASS

CASE 2: every surplus id already archived; archive bytes stay fixed
{
  "ok": true,
  "action": "prune",
  "state": "done",
  "kept": 1,
  "archived": 1,
  "ids": [
    "old-c3"
  ]
}
CASE 2 archive cmp and backlog assertions: PASS

CASE 3: done auto-prune after re-adding an archived id
{
  "ok": true,
  "action": "done",
  "pruned": 1,
  "task": {
    "id": "decision-a1",
    "title": "decision-a1",
    "state": "done",
    "kind": null,
    "repo": null,
    "priority": null,
    "created": null,
    "closed": "2026-10-01",
    "deps": [],
    "hold": null,
    "links": [],
    "body": null,
    "blocked": false,
    "blocked_by": [],
    "held": false
  }
}
CASE 3 archive cmp and backlog assertions: PASS

CASE 4: prior queued and in-flight forms prevent duplicate archive IDs
{
  "ok": true,
  "action": "prune",
  "state": "done",
  "kept": 1,
  "archived": 2,
  "ids": [
    "queued-b2",
    "flight-c3"
  ]
}
CASE 4 archive cmp: PASS

CASE 5: duplicate id within one prune batch
{
  "ok": true,
  "action": "prune",
  "state": "done",
  "kept": 1,
  "archived": 2,
  "ids": [
    "repeat-b2",
    "repeat-b2"
  ]
}

## Archived 2026-10-01
- [x] repeat-b2 - first copy
CASE 5 one archived record, first copy kept, both backlog copies removed: PASS

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.

  • Live validation: ✅ go - 3 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Prune removes completed tasks while keeping the first archived record for an existing ID, appending a fresh ID, and reporting both removed tasks ✅ pass live CLI prune and done transcript, cases 1–2
Completing a re-added decision with automatic pruning preserves its earlier archived title and resolution ✅ pass live CLI prune and done transcript, case 3
Prune recognizes archived queued and in-flight bullet forms and archives a repeated ID only once per batch ✅ pass live CLI prune and done transcript, cases 4–5
A failed backlog persist restores a prior archive and leaves an archive untouched when nothing was appended ⏸️ untested no The CLI has no controlled post-append persist-failure hook. Providing one would allow this failure path to be driven live; focused backend tests exercised it.
An untouched markdown backlog survives parse and render byte-exactly ⏸️ untested no The CLI has no public parse-and-render-without-mutation operation. A public harness for that operation would allow a live check; focused grammar tests exercised it.
  • corepack pnpm install --frozen-lockfile
  • corepack 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'
  • Ran corepack pnpm exec tsx bin/tasks-axi.ts against isolated backlog and archive files for transcript cases 1–5; checked JSON results, persisted records, and byte-identical archives with cmp.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

JasonUnifyAccount and others added 2 commits October 1, 2026 17:51
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>
@JasonUnifyAccount
JasonUnifyAccount merged commit 6405cc4 into main Oct 1, 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