Files
stack/agents/darkwing/work/slice1-s3/build.md
T
jason.woltjeandClaude Opus 5.5 4ac133dd8a docs(slice1): row 38 S3 build packet, probes and live rehearsal (darkwing)
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]>
2026-10-08 18:33:50 -05:00

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's credentials.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 plus updated cursor), 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) and fixtures/v2-shapes.json (responses recorded from the pinned image).
  • deploy/vikunja/compose.yaml and 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.mjs checks the digest against the runbook and the bind address.
  • live/run.mjs and 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 way request does; recordTask, which takes self snapshots from a role session and poll snapshots and events from the adapter (cap null), each from a closed list of event kinds; taskView. The refusal recording moved into #refused so both paths share it.
  • runtime.mjs: startBroker({tasks}) takes the adapter factory, refuses an adapter without handle or a valid timeout, 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-data trackers map 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: exports TASK_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-$BIZ account 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 updated from 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 bump updated at 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_tasks shape, assignees null when empty, done tasks in the cursor, due_date: null stored 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 refuses label-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 the trackers boot 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.baseUrl with a path. The adapter refuses it at boot with tracker-config, which is later than it should be.
  • task.close records the verdict citation and doesn't check it against the queue.
  • A coder's cross-role grant of task.reassign can 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.