Files
stack/agents/darkwing/work/slice1-s3/r1-to-r2.diff
T
jason.woltjeandClaude Opus 5.5 b144a03d47 docs(slice1): darkwing row 38 S3 round 2 packet
B1 truncates due dates to the second; B2 records what landed when the
final read fails; tests for V9, V21 and S5. Candidate manifest
e10e30e3, 28 files over c9ef7ee4. Mutants 46/63. Live provider cases
in test-release and test-task failed on a zai usage limit (429).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 19:27:17 -05:00

336 lines
16 KiB
Diff

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:<project>/<task>`, 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);
+});