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

201 lines
9.2 KiB
Markdown

# 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.