Files
stack/agents/filbert/work/slice1-s3-review/probe.test.mjs
T

202 lines
11 KiB
JavaScript

// Filbert, row 38 probes (round 1, with round 2 additions P1 to P3 at the end; D1 now expects the fix). Run from the candidate tree:
// node --test ~/filbert-scratch/r38/probe.test.mjs (with CAND set to the candidate root)
import test from 'node:test';
import assert from 'node:assert/strict';
const CAND = process.env.CAND;
const { world } = await import(CAND + '/packages/tasks/tests/world.mjs');
const { FakeVikunja } = await import(CAND + '/packages/tasks/src/fake.mjs');
const ready = async (t, edit) => {
const w = await world(t, edit);
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready');
return w;
};
const create = (w, args = {}) =>
w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args });
const code = (p) => p.then(() => 'ok', (e) => e.code ?? String(e));
// Vikunja v2.7.0 keeps due_date to the second (vk-due-probe.txt, the pinned image). The fake keeps
// what it is sent, so this wrapper truncates a written due_date the way the real one does.
const seconds = (fake) => async (url, init = {}) => {
if (init.body) {
const b = JSON.parse(init.body);
if (typeof b.due_date === 'string' && b.due_date.includes('.')) {
b.due_date = b.due_date.replace(/\.\d+Z$/, 'Z');
init = { ...init, body: JSON.stringify(b) };
}
}
return fake.fetch(url, init);
};
test('D1 schedule with milliseconds: the poll reports the bot\'s own due date as an external change', async (t) => {
const fake = new FakeVikunja();
const w = await ready(t, { fake, fetch: seconds(fake) });
const { task_ref } = await create(w);
const pm = w.caps.pm;
const r = await w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.456Z' });
const self = w.snapshots(task_ref).at(-1);
console.log('D1 self snapshot due_date', self.source, self.fields.due_date, 'returned digest', r.digest.slice(0, 12));
await w.adapter.tick('demo');
const ext = w.events('task.changed.external');
console.log('D1 external events after one tick', JSON.stringify(ext.map((e) => e.body.changed)));
const before = w.fake.requests.filter((x) => x.method === 'PATCH').length;
await w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.456Z' });
const after = w.fake.requests.filter((x) => x.method === 'PATCH').length;
console.log('D1 same schedule again sends PATCH', after - before);
// The pm's next call with the digest it was handed now conflicts.
const c = await code(w.call(pm, 'task.priority.change', { task_ref, priority: 2, expect: r.digest, decision: 'x' }));
console.log('D1 expect=returned digest ->', c);
assert.equal(ext.length, 0, 'r2: no false external change');
});
test('D2 whole seconds: no external change (control)', async (t) => {
const fake = new FakeVikunja();
const w = await ready(t, { fake, fetch: seconds(fake) });
const { task_ref } = await create(w);
await w.call(w.caps.pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z' });
await w.adapter.tick('demo');
console.log('D2 external events', w.events('task.changed.external').length);
});
test('W1 write lands, final read fails: refusal, no self record, poll calls it external', async (t) => {
const fake = new FakeVikunja();
let armed = false;
const wrap = async (u, i = {}) => {
const res = await fake.fetch(u, i);
if (armed && (i.method ?? 'GET') === 'POST' && /\/assignees$/.test(new URL(u).pathname)) {
armed = false;
fake.fault('GET', /^\/tasks\/1$/, 0);
}
return res;
};
const w = await ready(t, { fake, fetch: wrap });
const { task_ref } = await create(w);
armed = true;
const c = await code(w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }));
console.log('W1 caller sees', c);
console.log('W1 coder assigned in tracker', w.fake.task(1).assignees?.includes?.(w.bots.coder) ?? JSON.stringify(w.fake.task(1)).includes(String(w.bots.coder)));
console.log('W1 action.allowed task.assign', w.events('action.allowed').filter((e) => e.body.action === 'task.assign').length);
console.log('W1 task.assigned events', w.events('task.assigned').length, 'self snapshots', w.snapshots(task_ref).filter((s) => s.source === 'self').length);
await w.adapter.tick('demo');
const ext = w.events('task.changed.external');
console.log('W1 poll external events', JSON.stringify(ext.map((e) => e.body.changed)));
const again = await code(w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }));
console.log('W1 caller retries assign ->', again);
});
test('H1 a human, H2 a reader, and a non-holder cannot call a task verb', async (t) => {
const w = await ready(t);
const { task_ref } = await create(w);
console.log('H1 human task.assign', await code(w.call(w.human, 'task.assign', { task_ref, role: 'coder' })));
const reader = w.broker.bindReader ? w.broker.bindReader({ business: 'demo' }) : null;
if (reader) console.log('H2 reader task.assign', await code(w.call(reader, 'task.assign', { task_ref, role: 'coder' })));
const stale = w.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'other-run', harness: 'pi' });
console.log('H3 non-holder coder update', await code(w.call(stale, 'task.update.assigned', { task_ref, state: 'blocked' })));
});
test('R1 a relation to another project\'s task id under this project\'s ref', async (t) => {
const w = await ready(t);
const other = w.fake.project('Other', w.owner);
w.fake.share(other, w.bots.pm, 1);
const { task_ref } = await create(w);
const o = await w.ui('PUT', `/projects/${other}/tasks`, { title: 'other' });
const oid = o.json?.id ?? (await w.ui('POST', `/projects/${other}/tasks`, { title: 'other' })).json?.id;
const c = await code(w.call(w.caps.pm, 'task.schedule', { task_ref, relations: { add: [{ kind: 'related', task_ref: `vikunja:${w.project}/${oid}` }] } }));
console.log('R1 other task id', oid, 'schedule ->', c, 'relations on task 1', JSON.stringify(w.fake.task(1).related ?? w.fake.task(1).relations ?? null));
});
test('C1 a conflict event costs no authority; a role without the verb can still write task.conflict', async (t) => {
const w = await ready(t);
const { task_ref } = await create(w);
const rv = w.agent('reviewer');
const c = await code(w.call(rv, 'task.scope.change', { task_ref, title: 'x', expect: '0'.repeat(64) }));
console.log('C1 reviewer (no scope.change authority) wrong expect ->', c, 'task.conflict events', w.events('task.conflict').length);
const c2 = await code(w.call(rv, 'task.scope.change', { task_ref, title: 'x' }));
console.log('C1 reviewer right digest ->', c2);
});
test('M1 one task with a 500 in missing() stalls the tick; T1 the tick after recovers', async (t) => {
const w = await ready(t);
const a = await create(w);
const b = await create(w);
await w.ui('DELETE', '/tasks/1');
await w.ui('POST', '/tasks/2', { title: 'edited in ui' });
w.fake.fault('GET', /^\/tasks\/1$/, 500);
console.log('M1 tick ->', await code(w.adapter.tick('demo')), 'external', w.events('task.changed.external').length, 'missing', w.events('task.missing').length);
console.log('T1 next tick ->', await code(w.adapter.tick('demo')), 'external', w.events('task.changed.external').length, 'missing', w.events('task.missing').length);
void a, b;
});
// Round 2. P1: a create with a label and a relation whose final read fails. The fallback snapshot
// must digest the same as the next poll's read, or the poll reports the bot's create as external.
test('P1 create with a label and a relation, final read fails: snapshot matches the poll', async (t) => {
const fake = new FakeVikunja();
let armed = false;
const wrap = async (u, i = {}) => {
const res = await fake.fetch(u, i);
if (armed && (i.method ?? 'GET') === 'POST' && /\/tasks\/2\/relations$/.test(new URL(u).pathname)) {
armed = false;
fake.fault('GET', /^\/tasks\/2$/, 0);
}
return res;
};
const w = await ready(t, { fake, fetch: wrap });
const other = await create(w);
armed = true;
const r = await create(w, { labels: [w.label], relations: [{ kind: 'related', task_ref: other.task_ref }], due_date: '2026-11-01T10:20:30.789Z' });
const s = w.snapshots(r.task_ref).at(-1);
console.log('P1 create ->', r.task_ref, 'snapshot', s?.source, 'labels', JSON.stringify(s?.fields.labels), 'due', s?.fields.due_date, 'digest==returned', s?.digest === r.digest, 'task.created', w.events('task.created').length);
await new Promise((x) => setTimeout(x, 20));
await w.adapter.tick('demo');
const ext = w.events('task.changed.external').filter((e) => e.subject === r.task_ref);
console.log('P1 poll external events for the new task', JSON.stringify(ext.map((e) => e.body.changed)));
assert.equal(ext.length, 0);
});
// P2: a PATCH that lands but answers 500 is settled by the step's re-read; then the final read
// fails. landed counts the settled step, so the verb succeeds.
test('P2 step settled by re-read, then the final read fails: success', async (t) => {
const fake = new FakeVikunja();
let state = 0;
const wrap = async (u, i = {}) => {
const m = i.method ?? 'GET';
const p = new URL(u).pathname;
const res = await fake.fetch(u, i);
if (state === 1 && m === 'PATCH' && /\/tasks\/1$/.test(p)) state = 2;
else if (state === 2 && m === 'GET' && /\/tasks\/1$/.test(p)) {
state = 3;
fake.fault('GET', /^\/tasks\/1$/, 0);
}
return res;
};
const w = await ready(t, { fake, fetch: wrap });
const { task_ref } = await create(w);
fake.fault('PATCH', /^\/tasks\/1$/, 500, { apply: true });
state = 1;
const c = await code(w.call(w.caps.pm, 'task.schedule', { task_ref, due_date: '2026-12-02T00:00:00.000Z' }));
console.log('P2 caller sees', c, 'state', state, 'due in tracker', fake.task(1).due, 'schedule events', w.events('task.scheduled').length, 'last snapshot', w.snapshots(task_ref).at(-1).source, w.snapshots(task_ref).at(-1).fields.due_date);
assert.equal(c, 'ok');
});
// P3: a verb with nothing to write (the due date it already has) and a failed final read.
test('P3 nothing to write, final read fails', async (t) => {
const fake = new FakeVikunja();
let armed = false;
let gets = 0;
const wrap = async (u, i = {}) => {
const m = i.method ?? 'GET';
if (armed && m === 'GET' && /\/tasks\/1$/.test(new URL(u).pathname) && ++gets === 1) {
const res = await fake.fetch(u, i);
fake.fault('GET', /^\/tasks\/1$/, 0);
armed = false;
return res;
}
return fake.fetch(u, i);
};
const w = await ready(t, { fake, fetch: wrap });
const { task_ref } = await create(w, { due_date: '2026-11-01T00:00:00.000Z' });
const patches = fake.requests.filter((x) => x.method === 'PATCH').length;
armed = true;
const c = await code(w.call(w.caps.pm, 'task.schedule', { task_ref, due_date: '2026-11-01T00:00:00.000Z' }));
console.log('P3 caller sees', c, 'PATCHes sent', fake.requests.filter((x) => x.method === 'PATCH').length - patches);
});