Candidate for #1520 against 81339889: packages/tasks and the bus task
verbs. Live run rehearsed on a scratch v2.7.0 container; the estate run
waits for T236's base URL.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
9.2 KiB
Row 38, S3: tasks (the Vikunja adapter, broker task verbs, sync)
Darkwing, 2026-10-08. Issue #1520, reviewer Filbert. Brief:
docs/plans/2026-10-04_slice-1.md, section "Slice 1 S3". Design:
addendum B sections 3, 4 and 7, decisions 68 and 70. Nothing in the
candidate is committed, staged or pushed.
Base is 81339889 (refactor HEAD at gate time). The candidate was built on
bee89d10, and nothing under packages/ or scripts/ changed between the
two. In a fresh worktree at 81339889 the patch applies and the result
matches the manifest 28/28.
The row can reach review but can't close. The brief's gate wants the live run against the instance from row SR, and that waits for T236's base URL. What's here is the same run rehearsed against a scratch container.
Files
build.patch (sha256
eb225e5305f50811354604ee45a6e0a7489d9a76543615b2caaba102c13d5700)
changes 5 files under packages/bus/src/ and adds 23.
build-manifest.sha256 (sha256
be8dbd8ca03ca7264666ba171bd7bffbaaa08c6b265d60e7d587f1bc53897daf) pins
all 28 after the patch.
New, packages/tasks/:
src/vikunja.mjs: the v2 client. Every call goes through the broker'scredentials.use(), so the adapter never holds a token. Maps Vikunja's answers to refusal codes.src/startup.mjs: the six startup checks.src/verbs.mjs: the eight task verbs.src/sync.mjs: the tick (board read plusupdatedcursor), the reconcile walk, the comment check and missing-task handling.src/adapter.mjs: the factory the broker loads, one serial queue per business, timers, credential state,status().src/digest.mjs,src/index.mjs,src/fake.mjs(FakeVikunja, the recorded fake of the v2 routes).tests/: 44 tests in 6 files,world.mjs(a broker, credentials and the fake wired together) andfixtures/v2-shapes.json(responses recorded from the pinned image).deploy/vikunja/compose.yamland its README: the bundled option, REQ-TASK-3. The upstream image by digest, published on 127.0.0.1, no secret in the file.tests/deploy.test.mjschecks the digest against the runbook and the bind address.live/run.mjsand its README: the live run.README.md,package.json.
Changed, packages/bus/ (registering the task verbs, as the brief allows):
broker.mjs:TASK_VERBS;requestTask, which checks the caller, runs the secret check on args and result, and records a refusal the same wayrequestdoes;recordTask, which takes self snapshots from a role session and poll snapshots and events from the adapter (capnull), each from a closed list of event kinds;taskView. The refusal recording moved into#refusedso both paths share it.runtime.mjs:startBroker({tasks})takes the adapter factory, refuses an adapter withouthandleor a validtimeout, and closes it.server.mjs: task verbs go to the adapter, async, with the socket idle timeout raised to the adapter's 60 s. Other verbs stay synchronous.process.mjs: a plain-datatrackersmap in the boot config loads the adapter. Decision 70 puts the poller and reconcile in the broker runtime, and this is how the host process gets them.index.mjs: exportsTASK_VERBS.tests/tasks.test.mjs: 9 tests for the above.
No file outside packages/bus and packages/tasks changes. The bus
schema (task_snapshots, task_current, tasks_open) was already in S2.
Probes
probes.md in this directory has
every probe of addendum B section 7. The results the brief asked for:
- Labels (section L), the first open point. With the runbook as written,
a bot sees every label its owner made, in any project, and can attach
them. A bot owned by a separate
svc-$BIZaccount sees only labels on tasks it can read. Decision 68 took that: svc owns the bots and the labels, and the broker keeps the label-id allowlist as a second guard. Startup check 6 refuses a business whose pm can't see its configured labels. - The async bump lag (section U). Comments, assignees and relations bump
updatedfrom an async listener. On an idle server the bump showed 0 ms after the 201. Under load one read straight after missed it and the next, 5 s later, had it. That bounds the lag at 5 s, and the 60 s window decision 68 set is twelve times that. Moving between open buckets doesn't bumpupdatedat all, which is why every tick reads the whole board. - probe-s7g (section G) checked the reads the code relied on without a
probe: the
related_tasksshape,assigneesnull when empty, done tasks in the cursor,due_date: nullstored as year 1, the missing id, the id-keyed walk. Each one held.
Decisions 68 and 70
- svc-$BIZ owns the bots: a runbook matter (T236), not code. The adapter checks what follows from it, the labels at startup.
- Label-id allowlist:
tracker.labels; any other id refuseslabel-not-allowed. - Poller: a full board read every tick, the cursor from the previous
tick's start minus 60 s, floored to the second, and a digest dedupe
against
task_current. - The broker hosts the poller and reconcile:
startBroker({tasks})and thetrackersboot config.
A defect the tests found
The first time the comment check looks at a task, it counts comments
created at or after a bound. For the reconcile that bound is the start
of the previous reconcile, to the millisecond. A comment read back from
GET /tasks/{t}/comments has created in whole seconds (only the POST
answer carries nanoseconds, see fixtures/v2-shapes.json). So a comment
posted after the previous reconcile, in the same second, read as older
than the bound and wasn't counted. foreignComments in sync.mjs now
floors the bound to the second. The cost is that on a first look a
comment up to a second older than the bound can be counted. After the
first look the check goes by comment id, so this doesn't repeat.
Live run, rehearsed
live-rehearsal.txt (sha256
2a79eda46af5bb20b3aa0f5e5de959320b310a299b1c157c2712df9da74c012f) is
live-run.txt from the rehearsal: the real
broker, adapter and socket clients against a scratch v2.7.0 container,
the pinned digest, on 127.0.0.1 with its own data directory. Nothing
touched the estate instance or tasks.mosaicstack.dev.
rehearse.mjs and rehearse-setup.json are the scratch setup that
produced it. rehearse.mjs registers svc-demo, builds the project and
board, makes the label and four bots, mints their tokens into
environment variables only, and runs run.mjs. It isn't in the
candidate.
The first rehearsal failed the priority and scope steps with
decision-required. The pm holds both verbs cross-role, and the run
didn't raise a decision. The run now has the pm raise one and the
operator resolve it through the human CLI binding, and it checks the
refusal without one and the refusal on reuse. The second rehearsal
went as expected in every step. Its two ticks and the reconcile after
the run's own writes recorded 0 snapshots, so the dedupe works against
a real Vikunja. The log was checked for every token and for "bearer"
before it was written.
For the estate run, the operator writes a setup file with the T236
project, bot ids, label ids and token files (live/README.md), then
runs run.mjs with --base repeating the URL. That log goes into this
directory as live-run.txt in a later round.
Gate
In the worktree at 81339889 with the patch applied, run one after
another, each to its own file:
| Suite | Result |
|---|---|
node --test 'packages/tasks/tests/*.test.mjs' |
44/44 |
node --test 'packages/bus/tests/*.test.mjs' |
67/67 |
test-auth.sh |
15/15 |
test-conductor.sh |
17/17 |
test-config.sh |
24/24 |
test-discord.sh |
63/64, see below |
test-extension-package.sh |
18/18 |
test-foundation.sh |
44/44 |
test-queue.sh |
27/27 |
test-release.sh |
14/14 |
test-task.sh |
98/98 |
After the gate I corrected the expires format in live/README.md's
example (the broker takes YYYY-MM-DD, not a timestamp). That's the
only change since, and the tasks suite passed 44/44 again on it. The
patch and manifest above are the corrected ones.
test-discord.sh isn't clean, and I can't show it's unrelated by more
than reasoning and reruns. Two engine timing tests failed: "when pi has
not started a timed-out turn by the end of the abort grace..." and "a
timed-out run pi did start outlives the grace...". Nothing in
packages/discord or scripts/test-discord.sh imports packages/bus
or packages/tasks. The load average was 22 to 28 from other sessions
on the host. Reruns, full suite:
| Run | With the patch | Base 81339889 |
|---|---|---|
| 1 | 63/64 | not run |
| 2 | 64/64 | 64/64 |
| 3 | 63/64 | 64/64 |
| 4 | 64/64 | 64/64 |
| 5 | 64/64 | 64/64 |
engine.test.mjs alone passed 6/6 in each tree. Three of five with the
patch against four of four without is a small sample. If you want it
settled, the next step is the full suite on a quiet host.
Follow-ups, not in this row
- S1's business validator accepts a
tracker.baseUrlwith a path. The adapter refuses it at boot withtracker-config, which is later than it should be. task.closerecords the verdict citation and doesn't check it against the queue.- A coder's cross-role grant of
task.reassigncan never be used, because the field table lets only the pm write the assignee. Either the role file drops it or the field table changes. - The comment-count bound above.