fix(bus): keep the coverage refusal when its notice fails (#797)
Review M1 on62bece4a9: a failed notice insert or route threw past #refused, replacing request-evidence-required and skipping its audit. The notice is now best-effort; its transaction still rolls back whole. Tests: insert and route failures keep code, audit and lifecycle; retry publishes once. Both red on62bece4a9.
This commit is contained in:
1 parent
62bece4a9d
commit
c47a67de8d
2 files changed
+55
-6
No files matched your search
@@ -5459,13 +5459,17 @@ export class Broker {
|
||||
// advance rolls its transaction back on refusal. Publish the actionable PM
|
||||
// message afterwards, once per revision and coverage set, through the normal
|
||||
// bus route: the sandbox tool socket only exposes the refusal code.
|
||||
// The notice is diagnostic only: if any step of it fails, its transaction rolls back
|
||||
// and the original refusal and its audit event still go out. Retry publishes it.
|
||||
if (request.verb === 'request.advance' && e instanceof BusError && e.planningCoverage) {
|
||||
const state = this.#requestJournal.readRequest(s.business, args.request);
|
||||
const body = `Planning coverage for request ${state.request}, revision ${state.revision}:\n${e.reason}`;
|
||||
this.#store.transaction(() => {
|
||||
if (!this.#store.get('SELECT 1 FROM messages WHERE business=? AND from_role=? AND to_role=? AND body=?', s.business, NOTICE_ROLE, s.role, body))
|
||||
this.#continuationMessage(state, s.role, body, this.#continuationContext(state, s.role));
|
||||
});
|
||||
try {
|
||||
const state = this.#requestJournal.readRequest(s.business, args.request);
|
||||
const body = `Planning coverage for request ${state.request}, revision ${state.revision}:\n${e.reason}`;
|
||||
this.#store.transaction(() => {
|
||||
if (!this.#store.get('SELECT 1 FROM messages WHERE business=? AND from_role=? AND to_role=? AND body=?', s.business, NOTICE_ROLE, s.role, body))
|
||||
this.#continuationMessage(state, s.role, body, this.#continuationContext(state, s.role));
|
||||
});
|
||||
} catch { /* refusal below is the operation's result */ }
|
||||
}
|
||||
throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request);
|
||||
});
|
||||
|
||||
@@ -484,3 +484,48 @@ test('evidence already recorded while PM is absent does not duplicate its queued
|
||||
await f.bus.requestLifecycle.recover();
|
||||
assert.equal(messages(f, intent.result.intent.key).filter(m => m.to_role === 'pm').length, before);
|
||||
});
|
||||
|
||||
// Review M1 (planning fix r1): a failed coverage notice must not replace the refusal.
|
||||
const coverageNotices = db => db.prepare("SELECT * FROM messages WHERE from_role='_bus' AND body LIKE 'Planning coverage for request %' ORDER BY seq").all();
|
||||
const planningDb = async t => {
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
const { join } = await import('node:path');
|
||||
const f = await requestFixture(t); const sub = await f.submit(); f.approve(sub); await f.activate(sub);
|
||||
await f.bus.requestLifecycle.recover();
|
||||
const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close());
|
||||
return { f, db, args: { request: f.request, revision: f.journal.readRequest('demo', f.request).revision, op: 'm1' } };
|
||||
};
|
||||
|
||||
test('a failed notice insert keeps the coverage refusal, its audit and the lifecycle; retry publishes once', async t => {
|
||||
const { f, db, args } = await planningDb(t);
|
||||
const state = f.journal.readRequest('demo', f.request);
|
||||
const events = f.store.all('SELECT * FROM request_events'), effects = f.store.all('SELECT * FROM request_effects');
|
||||
const refusals = () => db.prepare("SELECT count(*) n FROM events WHERE kind='action.refused'").get().n;
|
||||
const before = refusals();
|
||||
db.exec("CREATE TRIGGER m1_fail_notice BEFORE INSERT ON messages WHEN NEW.from_role='_bus' AND NEW.body LIKE 'Planning coverage for request %' BEGIN SELECT RAISE(ABORT,'m1-publish-failed'); END");
|
||||
await assert.rejects(f.call('pm', 'request.advance', args), { code: 'request-evidence-required' });
|
||||
assert.equal(refusals(), before + 1);
|
||||
assert.equal(coverageNotices(db).length, 0, 'no partial notice');
|
||||
assert.deepEqual(f.journal.readRequest('demo', f.request), state);
|
||||
assert.deepEqual(f.store.all('SELECT * FROM request_events'), events);
|
||||
assert.deepEqual(f.store.all('SELECT * FROM request_effects'), effects);
|
||||
db.exec('DROP TRIGGER m1_fail_notice');
|
||||
await assert.rejects(f.call('pm', 'request.advance', args), { code: 'request-evidence-required' });
|
||||
assert.equal(coverageNotices(db).length, 1);
|
||||
});
|
||||
|
||||
test('a failed notice route rolls back message and delivery and the socket still returns the coverage refusal', async t => {
|
||||
const { Client } = await import('../src/client.mjs');
|
||||
const { f, db, args } = await planningDb(t);
|
||||
const state = f.journal.readRequest('demo', f.request);
|
||||
const deliveries = () => db.prepare('SELECT count(*) n FROM deliveries').get().n, before = deliveries();
|
||||
db.exec("CREATE TRIGGER m1_fail_route BEFORE INSERT ON deliveries WHEN NEW.op='routed' AND EXISTS(SELECT 1 FROM messages WHERE id=NEW.message AND from_role='_bus' AND body LIKE 'Planning coverage for request %') BEGIN SELECT RAISE(ABORT,'m1-route-failed'); END");
|
||||
const client = new Client({ path: f.bus.path, cap: f.caps.pm });
|
||||
await assert.rejects(client.call('request.advance', args), { code: 'request-evidence-required' });
|
||||
assert.equal(coverageNotices(db).length, 0, 'message rolls back with its failed route');
|
||||
assert.equal(deliveries(), before);
|
||||
assert.deepEqual(await client.call('request.get', { request: f.request }), state);
|
||||
db.exec('DROP TRIGGER m1_fail_route');
|
||||
await assert.rejects(client.call('request.advance', args), { code: 'request-evidence-required' });
|
||||
assert.equal(coverageNotices(db).length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user