Files
stack/agents/darkwing/work/queue-62/BUILD.md
T
jason.woltjeandClaude Opus 5.5 6b83edc36a docs(queue-62): row 62 candidate packet, project trees (darkwing)
Candidate manifest sha256 ec3f7a42f3bf6436634a5a83a12bb5678ca1e58a10ff4f66a23dfca7ff3f375f,
13 files on edc5de71. Gate on 6ae8b844: 13 suites green. The candidate
itself is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:12:05 -05:00

12 KiB
Raw Blame History

Row 62 (#1555): the adapter reads a project tree, candidate packet

Author: Darkwing. Reviewers: Dewey and Filbert. Brief: docs/plans/2026-10-11_multi-project-sync.md, row A. Base: edc5de71 (base.txt). It applies unchanged to 6ae8b844, where the gate ran: the commits between the two touch no file in this candidate. The feature is uncommitted; only this packet is committed. I pushed nothing.

Files

files.txt lists the 13 repository paths. candidate-manifest.sha256 has their sha256. build.patch is git diff --cached --binary edc5de71 over them: 13 files, +608/−73.

File Change
packages/business/src/vars.mjs tracker.descendants: project layer, boolean, default false
packages/business/tests/vars.test.mjs accepts true, refuses "true", 1 and the business layer; default false with provenance default
packages/cli/src/config.mjs one line, descendants: v["tracker.descendants"] in trackerFor() (decision 87)
packages/cli/tests/config.test.mjs the two tracker tests expect descendants: false; one more assertion that true reaches the boot entry
packages/cli/tests/trackers-boot.test.mjs line 44 expects descendants: false; see "Scope"
packages/tasks/src/adapter.mjs settings() takes descendants (absent means false, a non-boolean refuses tracker-config); ctx.tree
packages/tasks/src/startup.mjs inspect(ctx, project) is the install check for any project; new discover(ctx)
packages/tasks/src/sync.mjs tick and reconcile read each project in ctx.tree; watch-only reads; the leave-tree rule in missing()
packages/tasks/src/fake.mjs project trees: parents, the project list, archive, inherited access
packages/tasks/tests/tree.test.mjs new: 11 tests, listed below
packages/tasks/tests/shapes.test.mjs new test: the fake's project list against recorded shapes
packages/tasks/README.md new section "Project trees"; the host contract and startup notes
packages/tasks/live/README.md new section "Project trees"

verbs.mjs, vikunja.mjs, index.mjs and packages/bus/ are unchanged.

Scope

packages/cli/src/config.mjs and config.test.mjs are outside the brief's files. Sage added them as lead decision 87 (commit 9277de33), a decided scope addition: without the line, a host config with tracker.descendants set never reaches the adapter, and the gate read would need a packet script.

packages/cli/tests/trackers-boot.test.mjs is not named in decision 87. It follows from it. Line 44 compares boot.trackers with an exact object, so the config line breaks it: with the original line 44 and the candidate config.mjs the test fails, 0 passed and 1 failed. The change adds descendants: false to that object and nothing else. Reviewers and Sage should treat it as part of decision 87's addition, or tell me to take it out.

The extra config.test.mjs assertion, true reaching the boot entry, is mine. Decision 87 asked for the two false updates. Without the new assertion no test sent true through config.mjs, and the gate's live run is the first place that value is used.

How it works

With descendants off, ctx.tree holds the root and its view from startup and nothing else. Every read is the v0 read with the root's project id. The v0 tests pass unchanged, and tree test 1 checks that no GET /projects and no read under a child happens.

With it on:

  1. Startup is unchanged. The root's install check still refuses the business with tracker-install.
  2. Each reconcile, including the first one at start, calls discover(). It reads GET /projects, every page, and walks parent_project_id breadth-first from the root. It skips ids at or below 0 and archived projects. Each child gets inspect(). A pass gives its view, a tracker-install refusal gives null (watch-only), and any other refusal propagates. ctx.tree is replaced only after the walk finishes, so a failed reconcile keeps the previous tree.
  3. Tick and reconcile read each project in ctx.tree. An installed project is read through its board, as v0 reads the root. A watch-only one is read through walk(ctx, 'done = false', {}, project), and each task gets bucket WATCH_ONLY (0).
  4. Gone detection in a tick uses a set of refs placed by this tick's reads, not task ids. A task moved from A to B shows on B's board under the same id; keying by id would hide the old ref's tombstone. The placed-by-id mutant below fails the move test.
  5. missing() takes the project from the ref (parseRef). If the task still reads 200 in the same project but that project is no longer in ctx.tree, the task gets no-access with its last updated.

fieldsFor() builds the fields of a watch-only task with taskFields(task, 1) and then sets bucket: 0. taskFields checks the bucket is a positive integer, so the 1 is a placeholder that never reaches the ledger. The bus schema accepts 0 (an integer).

One line in reconcile changed meaning: if (!bucket) continue became if (bucket === undefined) continue. The old form would skip every watch-only task, since 0 is falsy. undefined is still the case it was written for, an open task the board didn't show.

The brief, line by line

Brief Where
tracker.descendants, project-layer boolean, default false vars.mjs; vars.test.mjs "types" and "merge"
The tree from GET /projects by parent_project_id discover(); tree tests 2 and 5
Discovery at startup and each reconcile, not each tick tree test 2 (tick has no GET /projects, reconcile starts with it); test 8 (a late child joins at reconcile, not tick)
Archived projects are not in the tree tree test 5 (with the list forced to include archived ones); test 9
Leave-tree rule, recorded and tested README "Project trees"; tree test 9, three ways
The ledger is never rewritten tree test 9: snapshots read open, then no-access with the same updated
Root install fails: refuse tree test 4
Child passes: installed. Fails: watch-only, business ready tree tests 2 and 3
Watch-only records title, done, assignees, comments; no bucket state tree test 11, decision 87's bucket 0
Moves root to child, child to child, child to outside tree test 7
Poll cost stated README; tree test 2 counts 6 GETs per tick and 11 per reconcile; probes.md P4
Cross-project read probed probes.md P4 and "Gate read"
The fake models the tree fake.mjs; probes.md "Fake"; shapes test 2
Descendants off matches v0 tree test 1; every pre-existing tasks test unchanged
verbs read-only; a child task refuses task-project verbs.mjs unchanged; tree test 11
No change to packages/bus/; no Vikunja writes files.txt; tree test 11 checks sync.mjs has no write method string

Tests

packages/tasks/tests/tree.test.mjs:

  1. With descendants off the adapter reads no project list and no child, as in v0.
  2. A three-level tree is read per project, and a project outside it is not. Root, an installed child, a watch-only grandchild and an outside project shared with the sync bot. The tick's six requests are checked in order; the reconcile is GET /projects plus 10.
  3. A watch-only child keeps the business ready, and a failed child read refuses as transient (a 503 on its views).
  4. A root without the installed buckets refuses with descendants on.
  5. The project list refuses without read_all, and skips archived projects and saved filters. A fetch wrapper adds is_archived=true, so the fake returns archived projects and the adapter's own check has to drop them.
  6. descendants must be true or false.
  7. A move inside the tree is a moved tombstone plus a snapshot under the new ref: root to A, A to B (watch-only), A to outside.
  8. A child made after startup joins the tree at the next reconcile.
  9. A child that leaves the tree has its tasks recorded missing, and the ledger keeps their history: archived, reparented to the top with a direct share, and reparented under a project the bot can't see. The hidden parent's title never reaches the bus.
  10. A task in a bucket outside the five keeps its raw bucket id.
  11. Bucket 0 on a watch-only task never becomes a bucket or a state. A move between open buckets records nothing; done then reopen records done twice; every snapshot has bucket 0; no task.state event; a verb refuses task-project; the source of sync.mjs has no .titles or .ids[ lookup and no write method string. This is the test decision 87 asked for.

shapes.test.mjs adds "the project list holds recorded project objects in the recorded list envelope". No recording holds a GET /projects list, so it checks the envelope against the recorded views list and each item against the recorded GET /projects/{p} body, keys and types.

Mutants

out/mutants.sh edits one source file in the build worktree, runs the tests, and restores the file from the candidate. Each mutant fails at least one test (out/mutants.txt):

Mutant Fails
no-leave-tree-check: drop the ctx.tree.has(project) branch in missing() tree 9
no-watch-only-bucket: fieldsFor keeps the bucket it is given tree 2, 3, 7, 8, 11
placed-by-id: gone detection keyed by task id tree 7
keep-archived: discover() keeps archived projects tree 5
config-drops-descendants: config.mjs passes false cli config "trackers come from..."

Things a reviewer should know

  • The p.id <= 0 skip guards shape only. The walk starts at the root and follows positive parent ids, so it can't reach a negative id. The skip stops a saved filter from failing the parent_project_id check.
  • The fake's GET /projects/{p} body now has parent_project_id, position, created and updated, from a shared #projectJson. Shapes test 1 still matches it to the recorded body.
  • commentsAt and commentCount stay keyed by task id. Vikunja task ids are global, so a moved task keeps its comment state.
  • A watch-only child's open tasks come from its task list, so a tick costs the same two GETs as an installed project.
  • I noticed in the v2.7.0 source that creating a project under v2 also creates a default kanban with a backlog bucket. A child made in the UI is then watch-only until someone installs the five buckets by hand. Nothing in this row depends on it.
  • No probe in this row touched a live Vikunja. probes.md has the source evidence and the exact gate read.

Gate

The gate ran in a detached worktree of 6ae8b844 with build.patch applied and node_modules linked to the main checkout's (out/gate/apply.txt, out/gate/gate.sh). Every suite ran in order and each log is in out/gate/. test-task ran with DOCKER_HOST=unix:///nonexistent.

Every suite passed on this candidate (out/gate/summary.txt):

Suite rc Result
packages/tasks 0 63 passed, 0 failed
packages/business 0 60 passed, 0 failed
packages/bus 0 74 passed, 0 failed
packages/cli 0 83 passed, 0 failed
test-auth 0 15 passed, 0 failed
test-conductor 0 17 passed, 0 failed
test-config 0 24 passed, 0 failed
test-discord 0 66 passed, 0 failed
test-extension-package 0 18 passed, 0 failed
test-foundation 0 44 passed, 0 failed
test-queue 0 27 passed, 0 failed
test-release 0 14 passed, 0 failed
test-task 0 26 passed, 0 failed

After the run I removed the gate worktree's gate_default Docker network; it had no containers.

Two earlier gate runs were stopped. The first had no node_modules link, so test-discord failed its "pi binary present" check, 57 passed and 1 failed. The second I stopped to add the true assertion to config.test.mjs, which changed the candidate. Neither run's logs are in this packet; they were on the earlier candidate.

Gate read for Sage

probes.md "Gate read" has the exact requests: GET /api/v2/projects and GET /api/v2/tasks with a project in 19, 18, 20, 21 filter, sync key 9, GETs only. The adapter reads per project whatever read 2 answers; a 200 there opens a follow-up row.