docs(queue-62): row 62 round 2 candidate packet, R1 and six test gaps (darkwing)

Filbert's R1 (27279) and Dewey's matching blocking item (27280): a child
that answers 403 or 404 in a tick leaves the tree, and the root still
refuses. Tests for Filbert's N1-N3 and Dewey's three gaps. 18 mutants
killed; all 13 suites green at 122b1223. Manifest sha256 55316d88.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 14:55:12 -05:00
co-authored by Claude Opus 5.5
parent 63fb219b7c
commit 620c0e89fd
21 changed files with 1183 additions and 29 deletions
+198 -24
View File
@@ -107,7 +107,7 @@ index b7710013..d7cef73a 100644
t.after(() => host.close(0));
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
index 1b764e82..ae2789be 100644
index 1b764e82..2417ac48 100644
--- a/packages/tasks/README.md
+++ b/packages/tasks/README.md
@@ -1,7 +1,7 @@
@@ -149,7 +149,7 @@ index 1b764e82..ae2789be 100644
A startup refused with `tracker-unavailable`, `tracker-rate-limited` or
`service-failed` is tried again at the next poll. Any other refusal
@@ -239,6 +242,84 @@ mtime moved.
@@ -239,6 +242,90 @@ mtime moved.
`close()` stops the timers and waits for the queue. A verb still queued
at that point refuses with `tracker-closed`.
@@ -202,8 +202,9 @@ index 1b764e82..ae2789be 100644
+tombstone and nothing after it.
+
+A project leaves the tree when it is archived, moved under a parent
+outside the tree, or no longer shared with the sync bot. The next
+reconcile drops it, and each of its tasks the bus holds is read once:
+outside the tree, no longer shared with the sync bot, or deleted. The
+next reconcile drops it, or a tick before that (below), and each of its
+tasks the bus holds is read once:
+
+- The sync bot can still read the task, in that project: `task.missing`
+ with `no-access`. The business no longer reaches it.
@@ -212,8 +213,13 @@ index 1b764e82..ae2789be 100644
+
+The tombstone keeps the task's last `updated`. Nothing in the ledger is
+rewritten: the earlier snapshots stay and the tombstone is a new row.
+Ticks before that reconcile keep reading the project, and a new child
+joins at the next reconcile the same way.
+A tick doesn't read the project list, so it keeps reading an archived
+child, or one moved out with a share of its own, until that reconcile.
+A child that answers 403 or 404 in a tick (no longer shared, moved under
+a project the sync bot can't read, or deleted) leaves the tree at that
+tick, and its tasks get the same one-by-one read. A 403 or 404 on the
+root still fails the tick, and so does any other failure on a child. A
+new child joins at the next reconcile.
+
+Each tick reads per project. An installed project costs the board and
+the cursor walk, a watch-only one its open task list and the cursor
@@ -234,7 +240,7 @@ index 1b764e82..ae2789be 100644
## Refusals
| Code | Meaning |
@@ -277,6 +358,15 @@ every user or task any recording held, so a key one route adds, like
@@ -277,6 +364,15 @@ every user or task any recording held, so a key one route adds, like
The shape test catches a fake that invents a field, not one that puts a
real field on the wrong route.
@@ -309,7 +315,7 @@ index d564c645..f0f8cacf 100644
sync: use('@sync'),
as: (role, method, path, opts) => use(role)(method, path, opts),
diff --git a/packages/tasks/src/fake.mjs b/packages/tasks/src/fake.mjs
index fc55529a..2a9cfc14 100644
index fc55529a..30d9179e 100644
--- a/packages/tasks/src/fake.mjs
+++ b/packages/tasks/src/fake.mjs
@@ -6,9 +6,13 @@ import { NULL_DATE } from './digest.mjs';
@@ -346,7 +352,7 @@ index fc55529a..2a9cfc14 100644
for (const [kind, position] of [
['list', 100],
['gantt', 200],
@@ -115,6 +120,21 @@ export class FakeVikunja {
@@ -115,6 +120,27 @@ export class FakeVikunja {
share(project, user, permission = 0) {
this.#projects.get(project).shares.set(user, permission);
}
@@ -361,6 +367,12 @@ index fc55529a..2a9cfc14 100644
+ archive(project, archived = true) {
+ for (const p of this.#projects.values()) if (p.id === project || this.#ancestors(p.id).includes(project)) p.archived = archived;
+ }
+ // Vikunja deletes a project's descendants and every task in them with it.
+ deleteProject(project) {
+ const gone = [...this.#projects.keys()].filter((id) => id === project || this.#ancestors(id).includes(project));
+ for (const t of [...this.#tasks.values()]) if (gone.includes(t.project)) this.#tasks.delete(t.id);
+ for (const id of gone) this.#projects.delete(id);
+ }
+ // A saved filter or Favorites: a negative id in the project list, as Project.ReadAll appends.
+ pseudoProject(user, id = -1, title = 'Favorites') {
+ this.#pseudo.push({ id, title, user });
@@ -368,7 +380,7 @@ index fc55529a..2a9cfc14 100644
bucket(view, title) {
const id = ++this.#seq.bucket;
this.#buckets.set(id, { id, title, view, position: id * 100 });
@@ -231,11 +251,39 @@ export class FakeVikunja {
@@ -231,11 +257,39 @@ export class FakeVikunja {
});
return id;
}
@@ -412,7 +424,7 @@ index fc55529a..2a9cfc14 100644
}
#userJson(id) {
const u = this.#users.get(id);
@@ -378,7 +426,15 @@ export class FakeVikunja {
@@ -378,7 +432,15 @@ export class FakeVikunja {
_project(user, [p]) {
const r = this.#projectFor(user, p);
if (r.error) return r.error;
@@ -504,7 +516,7 @@ index 9a2c5bc5..f3bdc3d5 100644
+ return tree;
+}
diff --git a/packages/tasks/src/sync.mjs b/packages/tasks/src/sync.mjs
index 811fb683..118f348e 100644
index 811fb683..0afd1c49 100644
--- a/packages/tasks/src/sync.mjs
+++ b/packages/tasks/src/sync.mjs
@@ -1,10 +1,12 @@
@@ -614,7 +626,16 @@ index 811fb683..118f348e 100644
else continue; // still open in the project: the board read raced a move, the next tick sees it
} else if (outcome(x) === 'not-found' || x.status === 403) {
updated = c.updated;
@@ -151,35 +169,44 @@ export async function tick(ctx) {
@@ -147,39 +165,58 @@ async function missing(ctx, current, refs, out) {
const record = (ctx, out) => {
if (out.snapshots.length || out.events.length) ctx.broker.recordTask({ business: ctx.business, ...out });
};
+// A child that answers 403 or 404 left the tree after the last reconcile: no longer shared, moved
+// under a project the bot can't read, or deleted. The tick drops it from ctx.tree, and missing()
+// reads its open tasks one by one, as after a reconcile. The root still refuses.
+const leftTree = (ctx, project, e) =>
+ project !== ctx.project && e instanceof BusError && (e.code === 'tracker-forbidden' || e.code === 'tracker-not-found');
export async function tick(ctx) {
const startMs = ctx.clock();
const readAt = iso(startMs);
const sinceMs = Math.floor(((ctx.lastStart ?? startMs) - WINDOW_MS) / 1000) * 1000;
@@ -622,9 +643,14 @@ index 811fb683..118f348e 100644
- const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`)).map((t) => [t.id, t]));
+ const reads = [];
+ for (const [project, view] of ctx.tree) {
+ const open = await openTasks(ctx, project, view);
+ const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`, {}, project)).map((t) => [t.id, t]));
+ reads.push({ project, view, open, hits });
+ try {
+ const open = await openTasks(ctx, project, view);
+ const hits = new Map((await walk(ctx, `updated > ${floorSecond(sinceMs)}`, {}, project)).map((t) => [t.id, t]));
+ reads.push({ project, view, open, hits });
+ } catch (e) {
+ if (!leftTree(ctx, project, e)) throw e;
+ ctx.tree.delete(project);
+ }
+ }
const current = new Map(ctx.broker.taskView(ctx.business, 'current').map((x) => [x.task_ref, x]));
const out = { snapshots: [], events: [] };
@@ -682,7 +708,7 @@ index 811fb683..118f348e 100644
await missing(ctx, current, gone, out);
record(ctx, out);
ctx.lastStart = startMs;
@@ -187,27 +214,32 @@ export async function tick(ctx) {
@@ -187,27 +224,32 @@ export async function tick(ctx) {
}
// The hourly full read, and the first read at startup. comment_count catches comments on tasks the
// cursor window missed; a changed count is checked against the comment list before it is reported.
@@ -775,10 +801,10 @@ index 3859dd22..c16f1f44 100644
for (const [name, r] of Object.entries(recorded)) {
diff --git a/packages/tasks/tests/tree.test.mjs b/packages/tasks/tests/tree.test.mjs
new file mode 100644
index 00000000..9410779b
index 00000000..f03de85b
--- /dev/null
+++ b/packages/tasks/tests/tree.test.mjs
@@ -0,0 +1,276 @@
@@ -0,0 +1,424 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
@@ -966,7 +992,9 @@ index 00000000..9410779b
+ assert.equal(w.snapshots(ref(child, task)).at(-1).fields.title, 'renamed');
+});
+test('a child that leaves the tree has its tasks recorded missing, and the ledger keeps their history', async (t) => {
+ const w = await world(t, TREE);
+ // The fake's clock runs ahead on demand, so an edit gets a new `updated` second.
+ let ahead = 0;
+ const w = await world(t, { ...TREE, fake: { now: () => Date.now() + ahead } });
+ const children = {};
+ for (const name of ['archived', 'reparented', 'hidden']) {
+ children[name] = w.fake.project(name, w.owner, { parent: w.project });
@@ -983,13 +1011,20 @@ index 00000000..9410779b
+ w.fake.reparent(children.reparented, 0);
+ w.fake.reparent(children.hidden, launchpad);
+ await sleep(20);
+ // A tick reads the tree it has; the project list is read at reconcile.
+ assert.deepEqual(missing(w), []);
+ await w.adapter.reconcile('demo');
+ const refs = Object.keys(children).map((name) => ref(children[name], tasks[name]));
+ const [archived, reparented, hidden] = refs;
+ // A tick reads the tree it has, and the project list is read at reconcile. The hidden child's
+ // board answers 403, so the tick drops it at once; the other two still read until the reconcile.
+ await w.adapter.tick('demo');
+ assert.deepEqual(missing(w), [[hidden, { reason: 'no-access' }]]);
+ // An edit the reconcile reads after the project left keeps the tombstone's `updated` the last one
+ // the bus recorded.
+ ahead = 2000;
+ await w.ui('PATCH', `/tasks/${tasks.reparented}`, { title: 'edited outside' });
+ await w.adapter.reconcile('demo');
+ assert.deepEqual(
+ missing(w),
+ refs.map((r) => [r, { reason: 'no-access' }]),
+ [hidden, archived, reparented].map((r) => [r, { reason: 'no-access' }]),
+ );
+ for (const r of refs) {
+ const s = w.snapshots(r);
@@ -1005,6 +1040,145 @@ index 00000000..9410779b
+ assert.deepEqual(await w.adapter.tick('demo'), { snapshots: 0, events: 0 });
+ assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
+});
+test('a child deleted or hidden between reconciles drops out at the next tick, and the root still refuses', async (t) => {
+ const w = await world(t, TREE);
+ const deleted = w.fake.project('Deleted', w.owner, { parent: w.project });
+ w.fake.install(deleted);
+ // Watch-only, so its tick read is the task list, not a board.
+ const hidden = w.fake.project('Hidden', w.owner, { parent: w.project });
+ const kept = w.fake.project('Kept', w.owner, { parent: w.project });
+ const launchpad = w.fake.project('Launchpad', w.owner);
+ const tasks = {};
+ for (const [name, p] of Object.entries({ root: w.project, deleted, hidden, kept })) tasks[name] = await add(w, p, name);
+ await ready(w);
+ w.fake.deleteProject(deleted);
+ w.fake.reparent(hidden, launchpad);
+ await w.ui('PATCH', `/tasks/${tasks.root}`, { title: 'root renamed' });
+ await w.ui('PATCH', `/tasks/${tasks.kept}`, { title: 'kept renamed' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(state(w).state, 'ready');
+ assert.deepEqual(missing(w).sort(), [
+ [ref(deleted, tasks.deleted), { reason: 'not-found' }],
+ [ref(hidden, tasks.hidden), { reason: 'no-access' }],
+ ]);
+ // The root and the sibling changed in the same tick, and both are recorded.
+ assert.equal(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.title, 'root renamed');
+ assert.equal(w.snapshots(ref(kept, tasks.kept)).at(-1).fields.title, 'kept renamed');
+ // The next tick reads neither, and the reconcile finds neither.
+ const reads = await counted(w, () => w.adapter.tick('demo'));
+ assert.ok(!reads.some((r) => r.includes(`/projects/${deleted}/`) || r.includes(`/projects/${hidden}/`)), reads.join('\n'));
+ assert.deepEqual(await w.adapter.reconcile('demo'), { snapshots: 0, events: 0 });
+ // Any other failure on a child still refuses the tick, and the child stays.
+ w.fake.fault('GET', new RegExp(`^/projects/${kept}/tasks$`), 503);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-unavailable' });
+ await w.ui('PATCH', `/tasks/${tasks.kept}`, { title: 'kept again' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(kept, tasks.kept)).at(-1).fields.title, 'kept again');
+ // The root answering 403 or 404 still refuses the tick, and stays in the tree.
+ w.fake.unshare(w.project, w.bots.sync);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-forbidden' });
+ w.fake.share(w.project, w.bots.sync, 0);
+ await w.ui('PATCH', `/tasks/${tasks.root}`, { title: 'root again' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(w.project, tasks.root)).at(-1).fields.title, 'root again');
+ w.fake.deleteProject(w.project);
+ await assert.rejects(w.adapter.tick('demo'), { code: 'tracker-not-found' });
+});
+test('a reconcile that fails keeps the previous tree', async (t) => {
+ const w = await world(t, TREE);
+ const first = w.fake.project('First', w.owner, { parent: w.project });
+ const second = w.fake.project('Second', w.owner, { parent: w.project });
+ w.fake.install(second);
+ const task = await add(w, second, 'second');
+ await ready(w);
+ // The first child's install check fails, so the walk stops before it reaches the second.
+ w.fake.fault('GET', new RegExp(`^/projects/${first}/views$`), 503);
+ await assert.rejects(w.adapter.reconcile('demo'), { code: 'tracker-unavailable' });
+ await w.ui('PATCH', `/tasks/${task}`, { title: 'renamed' });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(w.snapshots(ref(second, task)).at(-1).fields.title, 'renamed');
+ assert.deepEqual(missing(w), []);
+});
+test("a closed task the cursor missed takes its own project's done bucket", async (t) => {
+ // An hour behind, so the cursor never returns the close and missing() reads the task.
+ const w = await world(t, { ...TREE, fake: behind });
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ const childBoard = w.fake.install(child);
+ const watched = w.fake.project('Watched', w.owner, { parent: w.project });
+ const tasks = { child: await add(w, child, 'child'), watched: await add(w, watched, 'watched') };
+ await ready(w);
+ for (const id of Object.values(tasks)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ await sleep(20);
+ await w.adapter.tick('demo');
+ const child1 = w.snapshots(ref(child, tasks.child)).at(-1);
+ const watched1 = w.snapshots(ref(watched, tasks.watched)).at(-1);
+ assert.deepEqual([child1.via, child1.fields.done, child1.fields.bucket], ['task', true, childBoard.buckets.done]);
+ assert.deepEqual([watched1.via, watched1.fields.done, watched1.fields.bucket], ['task', true, WATCH_ONLY]);
+ assert.notEqual(childBoard.buckets.done, w.installed.buckets.done);
+});
+test("closed tasks read at startup take their own project's done bucket", async (t) => {
+ const w = await world(t, TREE);
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ const childBoard = w.fake.install(child);
+ const watched = w.fake.project('Watched', w.owner, { parent: w.project });
+ const tasks = { child: await add(w, child, 'child'), watched: await add(w, watched, 'watched') };
+ for (const id of Object.values(tasks)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ await ready(w);
+ const child1 = w.snapshots(ref(child, tasks.child)).at(-1);
+ const watched1 = w.snapshots(ref(watched, tasks.watched)).at(-1);
+ assert.deepEqual([child1.via, child1.fields.done, child1.fields.bucket], ['reconcile', true, childBoard.buckets.done]);
+ assert.deepEqual([watched1.via, watched1.fields.done, watched1.fields.bucket], ['reconcile', true, WATCH_ONLY]);
+});
+test("a closed task the cursor returns takes its own project's done bucket", async (t) => {
+ // Two closes per project: one before the tick, so the open read leaves it out, and one between the
+ // open read and the cursor walk, so both reads return it. `race` closes the second kind.
+ const race = new Map();
+ const w = await world(t, {
+ ...TREE,
+ fetch: async (url, init) => {
+ const r = await w.fake.fetch(url, init);
+ for (const [project, id] of race)
+ if (new URL(url).pathname.startsWith(`/api/v2/projects/${project}/`)) {
+ race.delete(project);
+ await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ }
+ return r;
+ },
+ });
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ const childBoard = w.fake.install(child);
+ const watched = w.fake.project('Watched', w.owner, { parent: w.project });
+ const before = { [child]: await add(w, child, 'child'), [watched]: await add(w, watched, 'watched') };
+ const during = { [child]: await add(w, child, 'child raced'), [watched]: await add(w, watched, 'watched raced') };
+ await ready(w);
+ for (const id of Object.values(before)) await w.ui('PATCH', `/tasks/${id}`, { done: true });
+ for (const [project, id] of Object.entries(during)) race.set(+project, id);
+ await sleep(20);
+ await w.adapter.tick('demo');
+ assert.equal(race.size, 0);
+ for (const [project, bucket] of [[child, childBoard.buckets.done], [watched, WATCH_ONLY]])
+ for (const id of [before[project], during[project]]) {
+ const s = w.snapshots(ref(project, id)).at(-1);
+ assert.deepEqual([s.via, s.fields.done, s.fields.bucket], ['cursor', true, bucket]);
+ }
+});
+test('a task moved out of a child that leaves the tree at the same time is moved, not no-access', async (t) => {
+ const w = await world(t, TREE);
+ const child = w.fake.project('Child', w.owner, { parent: w.project });
+ w.fake.install(child);
+ const task = await add(w, child, 'leaving');
+ await ready(w);
+ w.fake.archive(child);
+ w.fake.moveToProject(task, w.project);
+ await sleep(20);
+ await w.adapter.reconcile('demo');
+ assert.deepEqual(missing(w), [[ref(child, task), { reason: 'moved', project: w.project }]]);
+ assert.equal(w.snapshots(ref(w.project, task)).at(-1).fields.title, 'leaving');
+});
+test('a task in a bucket outside the five keeps its raw bucket id', async (t) => {
+ const w = await ready(await world(t, TREE));
+ const backlog = w.fake.bucket(w.installed.view, 'Backlog');