Skip to content

Commit 15dd571

Browse files
committed
Expire segment assignments when their plan item is removed
1 parent 35b6c86 commit 15dd571

3 files changed

Lines changed: 21 additions & 1 deletion

File tree

‎docs/implementation/persistent-review-store.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ Reproduced a historical-owner retry failure after an amendment removed the ownin
4343
Reproduced continuation reapproval failing after a second plan amendment. Continuation records now include revision in their immutable primary key; the getter returns the latest approved revision, leaving earlier approvals intact. Repeating approval for the same revision is idempotent, with a separately reproduced regression. This changes the unreleased schema introduced by this PR, not a released database format.
4444

4545
Declined the duplicate-source-mapping finding as a correctness issue: the rewrites primary key already rejects a repeated source within a snapshot, and the surrounding transaction rolls back its new snapshot and all ledger writes. A new regression passed before any implementation change and confirms the unchanged snapshot, empty ledger, and absence of mappings after rejection. Final suite: 154 tests.
46+
47+
## Review round 4
48+
49+
Reproduced a saved assignment throwing in `applyChoices` after its target item was removed by amendment. Revision commits now discard assignments whose targets no longer exist, in the same transaction as the revision and request invalidation. Unrelated assignments and standalone acceptances remain. Removed assignments cannot reactivate if an ID is later reintroduced. No finding declined this round. Final suite: 155 tests.

‎runner/store.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export class Store {
7979
if (this.#run('UPDATE plans SET revision=? WHERE key=? AND revision=?', plan.revision, key, expected).changes !== 1) throw new Error('Stale plan revision.');
8080
this.#run('INSERT INTO revisions VALUES (?,?,?)', key, plan.revision, encode(plan));
8181
this.#run("UPDATE requests SET state='invalidated' WHERE key=? AND state IN ('pending','ready')", key);
82+
const items = new Set(plan.items.map(item => item.id));
83+
for (const row of this.#db.prepare('SELECT choice_key,data FROM choices WHERE key=?').all(key)) {
84+
const choice = decode<SegmentChoice>(row.data);
85+
if (choice.action === 'assign' && !items.has(choice.item!))
86+
this.#run('DELETE FROM choices WHERE key=? AND choice_key=?', key, row.choice_key!);
87+
}
8288
}
8389
createPlan(source: string | Uint8Array, format: 'json' | 'yaml', context: PlanContext, base: string, head: string): Plan {
8490
sha(base); sha(head);

‎test/store.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { once } from 'node:events';
66
import { afterEach, expect, it } from 'vitest';
77
import { Store, requireSupportedNode } from '../runner/store.ts';
88
import type { Plan, PlanContext, EditReply } from '../core/plan.ts';
9-
import { approveItem, approvalStates, choiceKeys } from '../core/approvals.ts';
9+
import { approveItem, approvalStates, choiceKeys, applyChoices } from '../core/approvals.ts';
1010
import { linkHistory, type Segment } from '../core/linking.ts';
1111
import { readHistory } from '../git/history.ts';
1212
const identity = { repositoryId: 'repo', taskId: 'task', planId: 'plan' };
@@ -200,3 +200,13 @@ it('rejects duplicate source SHA mappings and rolls back every resulting ledger/
200200
expect(store.getSnapshot(identity)).toEqual(before); expect(store.getLedger(identity)).toEqual([]);
201201
expect(store.getRewrites(identity, before.id)).toEqual([]);
202202
});
203+
it('expires assignments to removed plan items atomically with the amendment', () => {
204+
const { store } = fixture();
205+
const segment: Segment = { path: 'a', oldPath: 'a', kind: 'text', operation: '+', content: 'after\n', context: '', owners: [null], row: 'Unplanned', scope: 'unplanned', oldLine: null, newLine: 1, hunk: 0, sharesHunkWith: [] };
206+
store.saveReview(identity, state(store), [], [{ key: choiceKeys([segment], identity)[0]!, action: 'assign', item: 'P1' }]);
207+
const next = plan(); next.items[0]!.id = 'P2'; store.importRevision(JSON.stringify(next), 'json', context, 1);
208+
expect(() => applyChoices(store.getPlan(identity), [segment], store.getReview(identity).choices, identity)).not.toThrow();
209+
expect(store.getReview(identity).choices).toEqual([]);
210+
store.importRevision(JSON.stringify(plan()), 'json', context, 2);
211+
expect(store.getReview(identity).choices).toEqual([]);
212+
});

0 commit comments

Comments
 (0)