diff -ruN r1tree/packages/tasks/README.md gate2/packages/tasks/README.md --- r1tree/packages/tasks/README.md 2026-10-08 19:13:37.288575219 -0500 +++ gate2/packages/tasks/README.md 2026-10-08 19:13:37.275629637 -0500 @@ -122,6 +122,24 @@ create can't be checked that way, so an uncertain create is reported as `write-uncertain` and a person checks the board. +On success the self snapshot holds the fields the verb set, not what the +final read saw. If a person edits the task between the last write and +that read, the next poll still records the edit as theirs. + +The final read can fail after the writes landed. Its refusal would hide +them, so it isn't passed on: + +- Every write landed: the verb succeeds and records its event and a + snapshot of the fields it set. The snapshot's `updated` is the + compare-read's, since nothing newer was read. +- Some landed and a later one failed: `write-uncertain`, and nothing is + recorded. The poll records what the tracker holds. +- None landed: the failed write's own code. +- A create: the create's answer, plus the labels it wrote, in the + default bucket. `task.created` is recorded and the verb succeeds. A + refusal here would read as "nothing happened" and invite a second + create. + | Verb | Who | Arguments | Event | |---|---|---|---| | `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` | @@ -137,6 +155,13 @@ `vikunja:/`, and a ref in another project refuses with `task-project`. +Due dates have one-second precision. Vikunja v2.7.0 stores `due_date` +to the second, though a write's answer echoes the milliseconds it was +sent. The adapter drops the milliseconds before it writes, so +`2026-12-01T09:00:00.456Z` is stored, recorded and digested as +`2026-12-01T09:00:00.000Z`. Without that, the poll would read the bot's +own write back as a person's edit. + Details that aren't obvious from the table: - `task.create` needs `request`, the id of a `human.input` event of this diff -ruN r1tree/packages/tasks/src/fake.mjs gate2/packages/tasks/src/fake.mjs --- r1tree/packages/tasks/src/fake.mjs 2026-10-08 19:13:37.288781975 -0500 +++ gate2/packages/tasks/src/fake.mjs 2026-10-08 19:13:37.282873539 -0500 @@ -49,6 +49,11 @@ const bad = (detail, code) => ({ title: 'Bad Request', status: 400, detail, ...(code ? { code } : {}) }); const unprocessable = (detail) => ({ title: 'Unprocessable Entity', status: 422, detail }); const second = (ms) => new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z'); +// Vikunja v2.7.0 keeps a due date to the second; a write's answer echoes what was sent, in Go's form +// (review r1, r1-vk-due-probe.txt). The probe can't tell floor from rounding; the adapter sends +// whole seconds, so it doesn't matter to it. +const dueIn = (x) => (x && x !== NULL_DATE ? second(Date.parse(x)) : null); +const dueEcho = (x) => (x && x !== NULL_DATE ? new Date(x).toISOString().replace(/\.?0+Z$/, 'Z') : NULL_DATE); const fine = (ms) => new Date(ms).toISOString(); const page = (list, q) => { const per = Math.min(Math.max(Number(q.get('per_page')) || 50, 1), 50), @@ -480,7 +485,7 @@ description: body.description ?? '', done: false, doneAt: null, - due: body.due_date && body.due_date !== NULL_DATE ? body.due_date : null, + due: dueIn(body.due_date), priority: body.priority ?? 0, percent: body.percent_done ?? 0, labels: [], @@ -497,6 +502,7 @@ this.#tasks.set(t.id, t); const out = this.#taskJson(t, { empty: [] }); out.related_tasks = null; + if ('due_date' in body) out.due_date = dueEcho(body.due_date); out.created = fine(now); out.updated = fine(now); return { status: 201, body: out }; @@ -516,7 +522,7 @@ before = JSON.stringify(this.#taskJson(t)); if ('title' in body) t.title = body.title; if ('description' in body) t.description = body.description; - if ('due_date' in body) t.due = body.due_date && body.due_date !== NULL_DATE ? body.due_date : null; + if ('due_date' in body) t.due = dueIn(body.due_date); if ('priority' in body) t.priority = body.priority; if ('percent_done' in body) t.percent = body.percent_done; if ('done' in body && body.done !== t.done) { @@ -529,7 +535,9 @@ // bucket_id is echoed and not applied (probes.md, "Writes and moves"). if (JSON.stringify(this.#taskJson(t)) === before && !('bucket_id' in body)) return { status: 304 }; if (JSON.stringify(this.#taskJson(t)) !== before) this.#touch(t); - return { status: 200, body: this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 }) }; + const out = this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 }); + if ('due_date' in body) out.due_date = dueEcho(body.due_date); + return { status: 200, body: out }; } _remove(user, [id]) { const x = this.#taskFor(user, id, true); diff -ruN r1tree/packages/tasks/src/verbs.mjs gate2/packages/tasks/src/verbs.mjs --- r1tree/packages/tasks/src/verbs.mjs 2026-10-08 19:13:37.288829710 -0500 +++ gate2/packages/tasks/src/verbs.mjs 2026-10-08 19:13:37.282945050 -0500 @@ -39,9 +39,11 @@ /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x; +// Vikunja keeps due dates to the second (review r1, B1), so the milliseconds are dropped before the +// write. The snapshot, the event and an uncertain write's re-read then all hold what Vikunja holds. const due = (x) => { if (x !== null && !canonical(x)) refuse('invalid-request'); - return x; + return x === null ? null : `${x.slice(0, 19)}.000Z`; }; const priority = (x) => { if (!Number.isSafeInteger(x) || x < 0 || x > 5) refuse('invalid-request'); @@ -133,29 +135,36 @@ const work = plan.check(before, id); ctx.broker.authorize(cap, verb, { decision: plan.decision, target: args.task_ref }); let failure = null; + let landed = 0; try { - for (const [method, path, body, done] of work.steps) await step(role, method, path, body, id, done); + for (const [method, path, body, done] of work.steps) { + await step(role, method, path, body, id, done); + landed++; + } } catch (e) { failure = e instanceof BusError ? e : new BusError('adapter-failed'); } - let after; + let after = null; try { after = await read(id); - } catch (e) { - if (failure) throw failure; - throw e instanceof BusError ? e : new BusError('adapter-failed'); + } catch { + // handled below: a failed final read must not hide a write that landed (review r1, B2) } - // Expected fields on success, so a concurrent edit still shows as external on the next poll. - // On a failure the snapshot is what the tracker holds now. + // The final read failed after some writes and not others: what the tracker holds isn't known. + if (!after && failure) throw landed ? new BusError('write-uncertain') : failure; + // Expected fields on success, so a concurrent edit still shows as external on the next poll. When + // every write landed but the final read failed, that is still what the writes set, and `updated` + // is the compare-read's. On a failure the snapshot is what the tracker holds now. const fields = failure ? after.fields : work.expect(before.fields); + const seen = after ?? before; ctx.broker.recordTask({ cap, business: ctx.business, - snapshots: [{ task_ref: args.task_ref, updated: after.updated, etag: after.etag ?? null, digest: digest(fields), fields }], + snapshots: [{ task_ref: args.task_ref, updated: seen.updated, etag: after?.etag ?? null, digest: digest(fields), fields }], events: failure || !work.event ? [] : [{ ...work.event, subject: args.task_ref }], }); if (failure) throw failure; - return { task_ref: args.task_ref, digest: digest(fields), updated: after.updated }; + return { task_ref: args.task_ref, digest: digest(fields), updated: seen.updated }; }; function prepare(verb, args, role, mine) { const common = ['task_ref', 'decision', 'expect']; @@ -362,8 +371,14 @@ let after = null; try { after = await read(id); - } catch (e) { - failure ??= e instanceof BusError ? e : new BusError('adapter-failed'); + } catch { + // handled below, as in handle() + } + // Every write landed but the final read failed: the create's answer plus the labels written, in + // the default bucket. A refusal here would read as "nothing happened" and invite a second create. + if (!after && !failure) { + const fields = { ...taskFields(r.json, ctx.view.ids.todo), labels: sorted(plan.labels) }; + after = { fields, digest: digest(fields), etag: null, updated: normal(r.json.updated) }; } // task.created is recorded even when a later label or relation write failed: the task exists. ctx.broker.recordTask({ diff -ruN r1tree/packages/tasks/tests/sync.test.mjs gate2/packages/tasks/tests/sync.test.mjs --- r1tree/packages/tasks/tests/sync.test.mjs 2026-10-08 19:13:37.288953627 -0500 +++ gate2/packages/tasks/tests/sync.test.mjs 2026-10-08 19:13:37.283110988 -0500 @@ -196,3 +196,17 @@ (e) => e.code !== undefined && !e.message.includes(w.tokens.coder), ); }); +// The first look at a task counts only comments inside the window, so a restart doesn't replay +// every comment ever made as new. +test('the first look at a task counts only comments inside the window', async (t) => { + let shift = -2 * 3600000; + const w = await ready(t, { fake: { now: () => Date.now() + shift } }); + const { task_ref } = await create(w); + await w.ui('POST', '/tasks/1/comments', { comment: 'two hours ago' }); + shift = 0; + await w.ui('POST', '/tasks/1/comments', { comment: 'just now' }); + await sleep(20); + await w.adapter.tick('demo'); + const [e] = external(w, task_ref); + assert.equal(e.body.comments, 1); +}); diff -ruN r1tree/packages/tasks/tests/verbs.test.mjs gate2/packages/tasks/tests/verbs.test.mjs --- r1tree/packages/tasks/tests/verbs.test.mjs 2026-10-08 19:13:37.288964639 -0500 +++ gate2/packages/tasks/tests/verbs.test.mjs 2026-10-08 19:13:37.283122508 -0500 @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { world, sleep } from './world.mjs'; +import { FakeVikunja } from '../src/fake.mjs'; const ready = async (t, edit) => { const w = await world(t, edit); await w.adapter.start('demo'); @@ -280,3 +281,129 @@ assert.deepEqual(w.fake.task(1).labels, [w.label]); assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); }); +// Vikunja keeps due dates to the second (review r1, B1). The adapter drops the milliseconds before it +// writes, so its own snapshot, the digest it returns and the next poll all agree. +test('a due date with milliseconds is written to the second', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); + const a = await task(w, { due_date: '2026-11-01T00:00:00.789Z' }); + assert.equal(w.fake.task(1).due, '2026-11-01T00:00:00Z'); + assert.equal(w.snapshots(a.task_ref).at(-1).fields.due_date, '2026-11-01T00:00:00.000Z'); + const r = await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' }); + assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z'); + const s = w.snapshots(a.task_ref).at(-1); + assert.equal(s.fields.due_date, '2026-12-01T09:00:00.000Z'); + assert.equal(s.digest, r.digest); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); + const patches = () => w.fake.requests.filter((x) => x.method === 'PATCH').length; + const n = patches(); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-01T09:00:00.456Z' }); + assert.equal(patches(), n); + // The digest handed back is the one the next verb has to name. + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-02T09:00:00.456Z', expect: r.digest }); + // A landed PATCH whose answer was lost settles on the re-read. + w.fake.fault('PATCH', /^\/tasks\/1$/, 500, { apply: true }); + await w.call(pm, 'task.schedule', { task_ref: a.task_ref, due_date: '2026-12-03T09:00:00.456Z' }); + assert.equal(w.fake.task(1).due, '2026-12-03T09:00:00Z'); + await sleep(20); + assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 }); +}); +// Arms a fault on the final read: after `trigger` answers, the next GET of task 1 fails. +const thenFailRead = (trigger) => { + const fake = new FakeVikunja(); + let armed = false; + const fetch = async (url, init = {}) => { + const res = await fake.fetch(url, init); + if (armed && trigger(init.method ?? 'GET', new URL(url).pathname)) { + armed = false; + fake.fault('GET', /^\/tasks\/1$/, 0); + } + return res; + }; + return { edit: { fake, fetch }, arm: () => (armed = true) }; +}; +test('every write landed and the final read failed: the verb succeeds and records what it wrote', async (t) => { + const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/assignees')); + const w = await ready(t, f.edit); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + f.arm(); + const r = await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]); + assert.equal(w.events('task.assigned').length, 1); + const s = w.snapshots(task_ref).at(-1); + assert.equal(s.source, 'self'); + assert.deepEqual([...s.fields.assignees], [w.bots.coder]); + assert.equal(s.digest, r.digest); + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(w.events('task.changed.external').length, 0); + await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); +}); +test('some writes landed and the final read failed: write-uncertain, and nothing is recorded', async (t) => { + const f = thenFailRead((m, p) => m === 'POST' && p.endsWith('/tasks/1/labels')); + const w = await ready(t, f.edit); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + const n = w.snapshots(task_ref).length; + w.fake.fault('POST', /^\/tasks\/1\/labels$/, 403); + f.arm(); + await assert.rejects( + w.call(pm, 'task.schedule', { task_ref, due_date: '2026-12-01T09:00:00.000Z', labels: { add: [w.label] } }), + refusedWith('write-uncertain'), + ); + assert.equal(w.fake.task(1).due, '2026-12-01T09:00:00Z'); + assert.equal(w.snapshots(task_ref).length, n); +}); +test('a create whose final read fails succeeds and records task.created', async (t) => { + const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p)); + const w = await ready(t, f.edit); + w.agent('pm'); + f.arm(); + const r = await task(w, { due_date: '2026-11-01T00:00:00.000Z' }); + assert.equal(w.events('task.created').length, 1); + const [s] = w.snapshots(r.task_ref); + assert.equal(s.source, 'self'); + assert.equal(s.digest, r.digest); + await sleep(20); + await w.adapter.tick('demo'); + assert.equal(w.events('task.changed.external').length, 0); +}); +// The success snapshot holds what the verb wrote, not what the final read saw, so an edit that lands +// between the last write and that read is still a person's edit on the next poll. +test('an edit between the last write and the final read shows as external on the next poll', async (t) => { + const fake = new FakeVikunja(); + let w, + armed = false; + w = await ready(t, { + fake, + fetch: async (url, init = {}) => { + const res = await fake.fetch(url, init); + if (armed && init.method === 'POST' && new URL(url).pathname.endsWith('/tasks/1/assignees')) { + armed = false; + await w.ui('PATCH', '/tasks/1', { title: 'renamed in the ui' }); + } + return res; + }, + }); + const pm = w.agent('pm'); + const { task_ref } = await task(w); + armed = true; + await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); + assert.equal(w.snapshots(task_ref).at(-1).fields.title, 'a task'); + await sleep(20); + await w.adapter.tick('demo'); + const ext = w.events('task.changed.external'); + assert.equal(ext.length, 1); + assert.deepEqual(ext[0].body.changed, ['title']); +}); +test('task.created is recorded when a later label write fails', async (t) => { + const w = await ready(t); + w.agent('pm'); + w.fake.fault('POST', /^\/tasks\/\d+\/labels$/, 500); + await assert.rejects(task(w, { labels: [w.label] }), refusedWith('write-uncertain')); + assert.notEqual(w.fake.task(1), undefined); + assert.deepEqual(w.fake.task(1).labels, []); + assert.equal(w.events('task.created').length, 1); +});