fix(bus): keep the coverage refusal when its notice fails (#797)

Review M1 on 62bece4a9: 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 on 62bece4a9.
This commit is contained in:
jason.woltje committed 2026-11-05 20:47:27 -06:00
1 parent 62bece4a9d
commit c47a67de8d
2 files changed
+55 -6

No files matched your search

+10 -6
View File
@@ -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);
});