feat(intake): draft queued submissions without ever losing one (#223)
The governing rule is that a submission is the only irreplaceable thing in this pipeline. The photos are often the only copy of an item no longer in the sender's hands, so a missing key, an unreadable file, a failed call and three exhausted retries all end the same way: the item keeps its photos, stays pending, and waits. Nothing in this file deletes anything. An absent key returns early and spends no attempt. Counting it as a failure would mean a fortnight without a key exhausted the retries and marked every waiting submission failed, with nothing wrong with any of them. A failure leaves the row queued while tries remain, so the sweeper picks it up again, and failed once they are spent, so a dead submission stops costing money and waits for a person instead of retrying forever. Photos are read once and passed down rather than loaded again inside the drafting call — the first read already has to happen to check there is at least one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import { pool } from '../../src/db';
|
||||
import { applyDraft } from '../../src/intake/applyDraft';
|
||||
import { DraftOutcome } from '../../src/intake/draftListing';
|
||||
import { resetDb, closeDb } from './setup/testDb';
|
||||
import { draftQueued, MAX_ATTEMPTS } from '../../src/intake/draftingWorker';
|
||||
import { resetAnthropicClient } from '../../src/intake/anthropicClient';
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
@@ -149,3 +151,94 @@ describe('applying a draft', () => {
|
||||
expect(rows[0]?.ai_error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the drafting worker', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
resetAnthropicClient();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetAnthropicClient();
|
||||
});
|
||||
|
||||
// The case that must never lose a submission. An unconfigured environment is
|
||||
// a working one: the item keeps its photos and waits.
|
||||
it('leaves submissions queued when there is no key', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
|
||||
const result = await draftQueued();
|
||||
|
||||
expect(result).toEqual({ drafted: 0, failed: 0, skipped: 1 });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT state, attempts FROM item_drafts WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
expect(rows[0]?.state).toBe('queued');
|
||||
// Skipping is not an attempt. Otherwise a fortnight without a key would
|
||||
// exhaust the retries and mark everything failed with nothing wrong with it.
|
||||
expect(rows[0]?.attempts).toBe(0);
|
||||
});
|
||||
|
||||
it('does not touch a row that has already spent its attempts', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
await pool.query(`UPDATE item_drafts SET attempts = $2 WHERE item_id = $1`, [
|
||||
itemId,
|
||||
MAX_ATTEMPTS
|
||||
]);
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used';
|
||||
resetAnthropicClient();
|
||||
|
||||
const result = await draftQueued();
|
||||
|
||||
expect(result.drafted).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
});
|
||||
|
||||
// A submission whose files cannot be read fails without an API call, and
|
||||
// without the item or its row going anywhere.
|
||||
it('fails a submission with no readable photos, keeping the item', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used';
|
||||
resetAnthropicClient();
|
||||
|
||||
const result = await draftQueued();
|
||||
|
||||
expect(result.failed).toBe(1);
|
||||
|
||||
const draft = await pool.query(
|
||||
`SELECT state, attempts, ai_error FROM item_drafts WHERE item_id = $1`,
|
||||
[itemId]
|
||||
);
|
||||
expect(draft.rows[0]?.attempts).toBe(1);
|
||||
// One try spent, two left, so it stays reachable for the sweeper.
|
||||
expect(draft.rows[0]?.state).toBe('queued');
|
||||
expect(draft.rows[0]?.ai_error).toContain('no readable photos');
|
||||
|
||||
const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||||
expect(item.rows[0]?.status).toBe('pending');
|
||||
});
|
||||
|
||||
// Three tries, then it stops costing money and waits for a person. The item
|
||||
// and its photos survive that too.
|
||||
it('gives up after MAX_ATTEMPTS rather than retrying forever', async () => {
|
||||
const itemId = await seedSubmission();
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-ant-not-used';
|
||||
resetAnthropicClient();
|
||||
|
||||
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
||||
await draftQueued();
|
||||
}
|
||||
|
||||
const draft = await pool.query(`SELECT state, attempts FROM item_drafts WHERE item_id = $1`, [itemId]);
|
||||
expect(draft.rows[0]?.attempts).toBe(MAX_ATTEMPTS);
|
||||
expect(draft.rows[0]?.state).toBe('failed');
|
||||
|
||||
const item = await pool.query(`SELECT id, status FROM items WHERE id = $1`, [itemId]);
|
||||
expect(item.rows[0]?.status).toBe('pending');
|
||||
|
||||
// And it is not picked up again, so a dead submission stops spending money.
|
||||
expect(await draftQueued()).toEqual({ drafted: 0, failed: 0, skipped: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user