packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).
Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).
Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Tasks adapter, slice 1 S3
This package connects the broker to Vikunja v2. It handles the eight task
verbs, polls each business's project, and records what it reads as task
snapshots and events in bus.sqlite. It holds no token. Every call goes
through the broker's credentials.use(), so the token goes into the
Authorization header and nowhere else. No line it logs carries a token,
a comment body or a description.
Addendum B section 4 defines the verbs and section 7 the probes. The
probe results it relies on are in
agents/darkwing/work/slice1-s3/probes.md, cited below by section letter.
Host contract
The host forks packages/bus/src/process.mjs and puts a trackers map in
the boot config. The broker process loads this adapter only when the map
is present:
{op: 'boot', config: {dataRoot, businesses, readers, repoRoots,
trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12,
pollSeconds: 30, reconcileMinutes: 60}}}}
baseUrl is the origin only, for example https://tasks.example.org. The
adapter adds /api/v2 and refuses a URL with a path with
tracker-config. S1's business validator accepts a path today, so a
wrong value fails at boot, not at validation. That's a follow-up for S1.
pollSeconds defaults to 30 and must be at least 10. reconcileMinutes
defaults to 60 and must be at least 1.
From the business definition (busBusiness output) the adapter needs:
tracker.sync.botIdandtracker.sync.credentials.vikunja, the sync bot. Its credential reference is<business>/@sync.tracker.labels, a map from label title to label id. These are the only label ids a verb may add or remove.- for each role with a
trackerblock,tracker.botIdand its Vikunja credential. Exactly one of those roles must have definitionpm.
A business with no trackers entry has no tracker, and its task verbs
refuse with tracker-unconfigured. Any other problem in this config
refuses the whole boot with tracker-config.
To embed it without the process wrapper, pass the factory to
startBroker:
import { tasksAdapter } from '@mosaic/tasks';
await startBroker({dataRoot, businesses, tasks: tasksAdapter({trackers})});
The factory returns {handle, timeout, close}. timeout is 60 s
(TIMEOUT). A verb can make several Vikunja writes, and the socket server
raises its idle timeout to this value for task verbs. A client that calls
a task verb must wait at least that long too. Give up sooner and the
outcome is unknown, not failed.
Startup
The broker doesn't wait for Vikunja. Startup runs in the background, and
until it passes every verb for that business refuses with
tracker-starting. In order, it checks:
GET /inforeports v2.4 or later (tracker-version). Anything other than v2.7.0, the probed release, is logged asuntested-versionand allowed.- The sync credential
<business>/@syncexists (credential-unavailable) and no Vikunja credential of the business is past itsexpires_at(credential-expired). The adapter checks the date itself, because Vikunja accepts a token minted with a past expiry (probes.md, O). - The sync bot can read the project (
tracker-project). - The project has exactly one manual kanban view with the buckets
todo,in-progress,in-review,blockedanddone, each once.doneis the done bucket andtodothe default (tracker-install). - Each token is no broader than its role (
scope-too-broad). The sync bot's PATCH on a missing task must answer 401. Each role must get 404/4002 reading it and 401 deleting it, and each non-pm role 401 adding a label to it. The missing task is id 2147483647 (MISSING). The addendum said one more than the highest id the sync bot sees, but on a shared instance that id can be another project's task, which answers 403. The largest int32 is missing on any instance this stack will meet. A scope 401 comes before the 404 (probes.md, O). - The pm can see every configured label through
GET /labels(tracker-labels). Otherwise label writes would answer 403. A bot sees a label only through its owner (probes.md, L), sosvc-$BIZowns the labels.
Then it runs one reconcile, and the business is ready.
A startup refused with tracker-unavailable, tracker-rate-limited or
service-failed is tried again at the next poll. Any other refusal
stays until the broker restarts, because it needs a person to fix the
install or a token. A 401 at any step refuses with
tracker-unauthorized.
Verbs
All eight go through the same steps:
- The field-table writer check.
task.create,task.assign,task.reassign,task.schedule,task.priority.changeandtask.closewrite fields only the pm writes, so another role getsfield-writer. This runs beforeauthorize, so a refused call consumes no decision. - A compare-read with the sync token: the task, and its bucket from the board when it's open.
- If the caller passed
expect, a digest of the fields they last saw, and it differs, atask.conflictevent andtask-conflict. - A done task refuses every verb with
task-done. Reopening is a person's act. broker.authorizewith the decision id and the task ref.- The writes, each with the acting role's own token. Only the writes that change something are sent.
- A final read, then one self snapshot and its event.
A write that answers 5xx or doesn't answer is never retried. The adapter
reads the task again. If the write took effect, it continues. If not,
or the re-read fails too, the verb refuses with write-uncertain. A
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
updatedis 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.createdis 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 |
task.assign |
pm | task_ref, role |
task.assigned |
task.reassign |
pm | task_ref, role |
task.assigned |
task.schedule |
pm | task_ref; one or more of due_date, labels.{add,remove}, relations.{add,remove} |
snapshot only |
task.priority.change |
pm | task_ref, priority 0 to 5 |
snapshot only |
task.scope.change |
any role, by authority | task_ref; title and/or description |
snapshot only |
task.update.assigned |
the assigned role | task_ref; one or more of percent_done 0 to 1, state, comment |
task.state |
task.close |
pm | task_ref, verdict |
task.closed |
Every verb also takes decision and expect. A task ref is
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.createneedsrequest, the id of ahuman.inputevent of this business (request-not-foundotherwise), and arequirementlikeREQ-TASK-3.task.createdis recorded even when a label or relation write after the create fails. The task exists, and the verb then refuses with that write's code.task.assignrefuses a task a bot already holds (task-assigned).task.reassignrefuses one no bot holds (task-unassigned). Both leave people's assignments alone, and remove only bots the compare-read showed, because Vikunja answers 204 to removing someone who isn't assigned (probes.md, D).task.scheduleremoves only labels the compare-read showed, because removing an absent label answers 403 (probes.md, D). Every label id must be intracker.labels, or the verb refuses withlabel-not-allowed. That allowlist is the second guard behind label ownership (decision 68).task.update.assignedrefusesnot-assignedunless the caller's bot is an assignee.stateistodo,in-progress,in-revieworblocked.doneisn't a target here (invalid-state), because closing is the pm'stask.close. The comment text stays in Vikunja. The event recordscomment: trueand nothing of the text.task.closemoves the task into the done bucket.verdictis a citation of the review verdict in the queue. The adapter records it and doesn't check it. Checking it against the queue is a follow-up.- A coder role's
crossRolegrant oftask.reassigncan't be used. The writer check refuses it before authority is looked at. If a coder should hand work on, that's a change to the field table, not to the role file.
Verbs for one business run one at a time, in the same queue as the poll, so a poll never reads between two writes of a verb.
Sync
Every pollSeconds the adapter reads the whole board, then the task list
filtered on updated > the start of the previous tick minus 60 s
(WINDOW_MS), floored to the second. Decision 68 set both. The window
covers Vikunja's late updated bump (probes.md, U, which has the
measured lag). Moving a task between open buckets doesn't bump updated
at all, so the board read is what catches moves. The adapter drops any
read whose digest matches what task_current already holds.
Each read that differs becomes a poll snapshot, with via set to
board, cursor, task or reconcile, and a task.changed.external
event naming the fields that changed. A read no newer than the last self
snapshot for that task is skipped, and the next tick reads it again.
That keeps a poll that started before a verb's write from recording the
old state over it.
A task that was open and isn't on the board any more is read once. Done
gives a snapshot in the done bucket and a task.changed.external event.
Moved to another project, not readable (403) or deleted gives a
task.missing event with the reason (moved, no-access or
not-found) and a tombstone snapshot. Still open in the project means
the board read raced a move, and the next tick places it.
For each task the cursor returned with a new updated, the tick reads
the comments and counts those by anyone but this business's bots, newer
than the last one it saw. On the first look at a task it counts only
comments from inside the window, so a restart doesn't report old ones.
The event carries the count, never the text.
Every reconcileMinutes, and once at startup, a full walk reads every
task with expand=comment_count. A task whose count changed since the
last reconcile gets the same comment check. This catches comments on
tasks the cursor didn't return, so those show up within one reconcile
period, not one poll.
The adapter checks credential state after startup and after every tick.
Each of credential.expiring, credential.expired and
credential.changed is recorded once per instance per process. changed means the token file's inode, size or
mtime moved.
close() stops the timers and waits for the queue. A verb still queued
at that point refuses with tracker-closed.
Refusals
| Code | Meaning |
|---|---|
tracker-config |
the boot config or business definition is wrong |
tracker-unconfigured |
the business has no tracker |
tracker-starting |
startup hasn't passed yet |
tracker-closed |
the broker is shutting down |
tracker-unauthorized, tracker-forbidden |
Vikunja answered 401 or 403 |
tracker-not-found, task-not-found |
404 without and with Vikunja's code 4002 |
tracker-invalid |
Vikunja answered 400 or 422 |
tracker-rate-limited |
429 |
tracker-unavailable |
5xx or no answer on a read |
write-uncertain |
5xx or no answer on a write, not settled by a re-read |
tracker-shape, tracker-paging, tracker-unexpected |
Vikunja sent something the probes didn't show |
task-moved, task-placement, task-project |
the task is in another project, or on no bucket |
task-conflict, task-done, task-assigned, task-unassigned, not-assigned |
the task's state doesn't allow the verb |
field-writer, label-not-allowed, unknown-role, invalid-state, request-not-found, invalid-request |
the call itself is wrong |
Startup adds credential-unavailable, credential-expired,
tracker-version, tracker-project, tracker-install,
scope-too-broad and tracker-labels, listed above.
Tests
node --test tests/*.test.mjs from this directory. The tests use
FakeVikunja (src/fake.mjs, also exported as @mosaic/tasks/fake),
an in-memory Vikunja v2.7.0 that follows the probe notes.
tests/fixtures/v2-shapes.json holds responses recorded from the pinned
image. shapes.test.mjs checks that every key the fake sends is one
Vikunja sent in the same place, with the same JSON type.
One limit of that check: user and task objects are compared against
every user or task any recording held, so a key one route adds, like
comment_count, passes on any route. The fake may also leave keys out.
The shape test catches a fake that invents a field, not one that puts a
real field on the wrong route.
Nothing in these tests reaches a real Vikunja. The probes and the live run use a scratch container on 127.0.0.1, never a shared instance.
Bundled Vikunja
deploy/vikunja/ has the compose file for installs without their own
Vikunja. See its README.