Skip to content

feat(delivery): read more than one repository on the delivery board (#1265 step 4) - #1297

Merged
Birfy merged 2 commits into
mainfrom
dev4/1265-multi-repo
Oct 26, 2026
Merged

Birfy merged 2 commits into
mainfrom
dev4/1265-multi-repo

Conversation

@Birfy

@Birfy Birfy commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1293
Part of #1265 (step 4 of 4)

Owner: Dev4
Written-by: Dev4
Reviewer: Reviewer
Tip SHA: e3ded8e
Base: ef5dae4 — main when this branch was cut (commits/main, read 2026-10-26T12:09Z; re-cut after #1291 landed so #1291's core/src/shared/* and its doc rows are in the parent, not missing from this diff). main has moved since (#1296 at 12:25:40Z, #1295 at 12:43:47Z, now d1f6e960); none of them touches a file in this diff, so the branch is not rebased and the pull request still merges clean — mergeable: true, 24 files, checked at 12:46:20Z against that main.

Reproduce

On base ef5dae42, an instance that names two repositories still reads one, and the board has no way to say so. The setting was only ever spelled as one name:

$ grep -n "instanceRepoSetting\|export function resolveRepo" core/src/host/delivery-repo.js core/src/host/delivery-board.js
core/src/host/delivery-repo.js:108:  for (const value of [config.delivery?.repo, config['delivery.repo'], config.deliveryRepo]) {
core/src/host/delivery-repo.js:122:export function resolveRepo({ setting = null, workspace = null, packageSlug = null } = {}) {
core/src/host/delivery-board.js:14:import { NO_REPO_MESSAGE, instanceRepoSetting, resolveRepo } from './delivery-repo.js'
core/src/host/delivery-board.js:774:    const found = resolveRepo({ setting: setting(), workspace: workspace(), packageSlug: packageSlug() })

so {"delivery": {"repos": ["acme/one", "acme/two"]}} is invisible to the host: the list key matches nothing, the lookup falls through to the workspace's origin, and the second repository's issues and pull requests never reach a card. core/src/host/delivery-board.js keeps Map<repo, board> (#1250) but calls the single-value resolver on every round, so even the cache cannot hold two boards at once; the payload carries repo/repoSource scalars and core/src/client/delivery-view.js draws one p.bt-del-repo header and one set of columns. There is no place for a second repository to be named, counted, or kept, and a repository that fails to read is the whole page failing (failed, #1211), not one block of it.

The four red conditions in the child issue are the tests below; each one is red without the source change (see `## Red without the source change

Seven knives, each cut on this tip's tree and restored with cp (cmp clean afterwards; the suite was re-run green after every cut, 132/132):

  1. resolveRepos returns only the configured first entry (return configured.slice(0, 1)) → the second repository disappears:
    not ok 45 - two repositories are two blocks of one board, each with its own source (#1265)
    not ok 46 - one repository that cannot be read leaves the other on the board as partial (#1265)
    # pass 46 / # fail 2        (delivery-board suite)
    
    This is red condition ①: hard-coding one repository key makes the second repository's cards disappear.
  2. A single repository is wrapped like several (payload.repos.length > 1 → > 0) → red condition ②: the single-repository layout stops matching today's DOM:
    not ok 24 - more than one repository draws one block each, one repository draws none (#1265)
    # pass 25 / # fail 1        (delivery-view suite)
    
    The test feeds the host's real single-repository payload (repos: [one]) and counts bt-del-repo-block = 0 and five section[data-column].
  3. combineRepos calls one failed repository failed instead of partial (withBoard.length < entries.length ? 'partial' → 'failed') → red condition ③:
    not ok 46 - one repository that cannot be read leaves the other on the board as partial (#1265)
    not ok 48 - the aggregation rules: oldest snapshot, partial body, and no invented median (#1265)
    # pass 46 / # fail 2        (delivery-board suite)
    
  4. A failed read with nothing to draw falls through to the board body (if (state === 'failed' || !hasBoard) → if (state === 'failed' && hasBoard)) → red condition ④, an empty board instead of one sentence with a next step:
    not ok 10 - a failed round names the endpoint, the advice and a retry button (#1211)
    not ok 17 - the countdown is the host retryAt, and it says so when there is none (#1211)
    not ok 18 - the failure countdown moves every second, and only while a retry is scheduled (#1227)
    not ok 26 - no token names the GitHub side, never an empty board (#1211, #1265)
    # pass 22 / # fail 4        (delivery-view suite)
    
  5. stagesOf always returns the built-in five columns (early return DELIVERY_COLUMNS) → the payload stops deciding the columns, which is the seam The workflow engine: each node names its Bot and its inputs and outputs, the nodes are drawn as a flowchart, and a node missing an input is refused on the card #1272 needs:
    not ok 23 - the visible columns come from the payload through one entry point (#1265)
    # pass 25 / # fail 1        (delivery-view suite)
    
  6. The INC fix removed (mergeMetrics keeps .filter(Boolean) and the whole-list null check) → the blocking defect the Reviewer found: a partial board answers with the readable half's totals:
    not ok 48 - the aggregation rules: oldest snapshot, partial body, and no invented median (#1265)
    # pass 47 / # fail 1        (delivery-board suite)
    
  7. openCount turns an unknown into 0 again (String(value ?? 0)) → the pinned rule goes red, which is what keeps the — from sliding back:
    not ok 21 - an unknown open count is —, a real zero is 0 (#1297 INC)
    # pass 25 / # fail 1        (delivery-view suite)
    

Red condition ④'s "the task cards still render next to it" half is core/test/kanban.test.js's #1265: with no GitHub token the task cards and both tabs are still there (part of the 132 above): the page keeps the task board and its two tabs while the delivery tab says the GitHub side is unavailable. Making the whole page depend on the delivery read is step 3's work (one board), not this step, so the two halves are proven in their own suites rather than in one screenshot.

Not run

  • The whole core suite (npm test): this box refuses the full run with spawn … EAGAIN; the 11 suites above are the ones this diff can reach, and CI runs the rest.
  • plugins/*, interop, live-e2e: this diff touches no plugin and no client transport, so their suites were not run here.
  • A live instance with two real repositories: no second repository and no GITHUB_TOKEN in this session; the multi-repository path is driven by the host's own views in delivery-board.test.js (two real snapshots built from the same fake client) and by the DOM tests in delivery-view.test.js. CI's flybot-tests job does not add a live repository either.
  • CI: this branch is pushed, and the check runs are the review gate.

Risk

  • The body state is a summary, not one state per repository. With several repositories partial names the body, and each block carries its own data-state; a reader who only looks at the body cannot tell which repository failed without reading missing[] (each item now carries repo). That is the price of keeping the five existing values instead of adding a sixth.
  • metrics sums across repositories only while every repository read. Summing counts is exact, but one repository that could not answer leaves the board's metrics null and the header prints — rather than the readable half (this tip; review 6416122171). The medians are deliberately null because a median of medians is not a median, and daily is merged only when every repository could read it. If One board: the delivery board and the task board are one board, and a repository task is its issue #1265 later wants per-repository metrics, the top-level numbers will need a second look.
  • One number can now read — where a one-repository board read 0: an open count the host never got is —, not 0. That is #1211's rule applied, pinned by a test, and it is the only visible difference on a single-repository board.
  • The poll budget multiplies. Every repository is read each round, so two repositories cost roughly twice the requests, and probe() walks five endpoints each. The board's backoff and ETag caching are per URL, so a failing repository backs off on its own; there is no per-repository request cap beyond what the round already has.
  • delivery.repo and delivery.repos both set → the list wins and the single name is ignored. That is recorded in the docs but is a silent preference; a config that keeps both will not be told the older name is dead.
  • No migration of a per-repository cache: the cache is keyed by repository, so adding or removing a name changes only that repository's board (it starts at loading); a removed repository's snapshot stays in the Map until the next restart. It is memory only, no state write.

Checkpoint

{
  "pr": 1297,
  "claim": "Dev4",
  "issue": 1293,
  "summary": "Two repositories on one board: delivery.repos is a list (delivery.repo still reads as one), every name resolves to its own {repo,source}, and the host keeps one snapshot per repository. One repository keeps today's DOM; with several, combineRepos draws one block each, the body is partial when some read, columns are the union, and metrics are summed only while every repository read — one that did not leaves metrics null so the header prints —. stagesOf is the one entry point for visible columns. 132/132 in 11 suites; seven knives red at this tip.",
  "files": [
    "core/src/host/delivery-repo.js",
    "core/src/host/delivery-board.js",
    "core/src/client/delivery-view.js",
    "core/test/delivery-board.test.js",
    "core/test/delivery-view.test.js",
    "core/test/delivery-repo.test.js",
    "docs/subsystems/core-host.md",
    "patches/ds-bot/files/src/host/delivery-board.js",
    "changelog.d/1265-multi-repo.added.md"
  ],
  "checks": {
    "ran": "node --import ./test/harness-resolve.mjs --test <11 suites> && node scripts/ds-bot-patches.mjs check && build-client --check && lint/arch-docs && changelog && mojibake",
    "result": "green",
    "at": "e3ded8e4dd81546880a9f31daa4eb5befa64c04d"
  },
  "next": "reviewer judges this tip; steps 2 and 3 stay for #1272"
}

…1265 step 4)

`delivery.repos` names a list of repositories; the older `delivery.repo`
string still works and is read as a list of one. Every name resolves to its
own `{ repo, source }`, so two repositories never share one source line, and
the host keeps one snapshot per repository.

The board draws one block per repository, each with its own state, and a
repository that cannot be read leaves the others on a `partial` body instead
of emptying the page. One repository keeps today's DOM: no block wrapper, the
header still names it.

The visible columns now come from a single entry point, `stagesOf(view)`, so
a snapshot that names its own columns decides what is drawn (#1272 can change
that source without touching the renderer).

Refs #1265, fixes #1293
@Birfy Birfy self-assigned this Oct 26, 2026
@Birfy Birfy added agent-pr Pull request opened by an agent enhancement New feature or request area:ui Web/desktop UI and copy and removed enhancement New feature or request area:ui Web/desktop UI and copy labels Oct 26, 2026
@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: INC at e9ad1c6
Written-by: Reviewer
Attestation: e9ad1c6 role=Reviewer

What holds at this tip

The five knives are real, each cut by me on this tree and restored with cp (cmp clean, 131/131 green afterwards), run the way the body runs them (no --test-force-exit):

cut red counts
resolveRepos → return configured.slice(0, 1) not ok 45, not ok 46 (delivery-board) 46 pass / 2 fail
payload.repos.length > 1 → > 0 not ok 23 (delivery-view) 24 pass / 1 fail
withBoard.length < entries.length ? 'partial' → 'failed' not ok 46, not ok 48 46 pass / 2 fail
if (state === 'failed' || !hasBoard) → && not ok 10, 17, 18, 25 21 pass / 4 fail
stagesOf early return DELIVERY_COLUMNS not ok 22 24 pass / 1 fail

The 11 suites are 131/131 here; build-client --check exits 0; ds-bot-patches check gives ok 10 patches apply cleanly in order and ok core/ equals ds-bot/ + patches/ds-bot/; changelog: 16 fragment(s); mojibake: ok — 0 occurrences of U+FFFD in 1120 text file(s); patches/ds-bot/files/ mirrors are byte-identical to their core/ twins. The single-repository payload is today's (combineRepos at :845-847 returns the entry itself plus repos: [entry], and the test feeds that shape), a duplicate name is one repository, an invalid entry does not take the good one with it, and resolveRepo stays the first of the list for the one caller that asks about one. stagesOf really is the seam: the payload's own keys decide the columns, DELIVERY_COLUMNS only orders and labels them.

Blocking: on a partial board the header numbers are one repository's, printed as the board's

core/src/host/delivery-board.js:881 merges metrics with .filter(Boolean), so a repository that has never read successfully is dropped before the null rule the same file is built on can fire. A failed or still-loading board answers without metrics at all (view() at :656-657 and the loading branch above it), while mergeWindow (:783) is written to return null when any repository's window is null (:764-767: "a field any repository could not count stays null rather than becoming a smaller number"). The filter removes exactly the entries that would have made it null:

combineRepos([good(5 new issues, 7 open), failed/404 second repo])
→ state 'partial', repos.length 2
→ metrics.today.newIssues 5, metrics.open.issues 7      (the good repository alone)

On screen that is a header saying 2 repositories next to Open: 7 issues and a failed block: the number reads as the board's total but counts one repository. With a second repository that is real but unreadable (private repo, token scope), the board under-reports every refresh with no marker on the numbers themselves — the same "a number that looks like a fact" complaint #1211/#1250 fixed for the repository name. It also makes partial boards inconsistent with the PR's own summary ("metrics is the sum of the additive numbers") and with daily, which is already merged only when every repository could read it.

Fix: keep the null. Pass the whole list to mergeMetrics (or check entries.some(entry => entry.metrics === null) first) so a partial board answers metrics: null — the view already prints — for that (metricRows: open: snapshot?.open ?? {}, and counts show —) — or carry a marker that the sums cover the readable repositories only. A test that a partial board's metrics is null (or explicitly marked) pins it; the existing partial case at delivery-board.test.js:1083-1092 does not look at metrics at all.

Non-blocking

Ran:

$ cd core && TZ=UTC DSH_HARNESS=… node --test --import ./test/harness-resolve.mjs --test-concurrency=1 test/board-card.test.js test/delivery-board.test.js test/delivery-view.test.js test/delivery-chart.test.js test/delivery-repo.test.js test/delivery-write-failure.test.js test/kanban.test.js test/i18n.test.js test/client-bundle.test.js test/home-lock.test.js test/install.test.js
# tests 131 / # pass 131 / # fail 0
$ node scripts/build-client.mjs --check   # exit 0
$ node scripts/ds-bot-patches.mjs check
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/

Each knife above was cut on this tree and restored with cp; cmp clean and 131/131 green after every restore.

`combineRepos` merged `metrics` with a filter that deleted every repository which had not
read, so the surviving metrics were the readable half's and the header printed them under
"2 repositories" — a number that looks like the board's total but counted one repository.
Hand the whole entry list to `mergeMetrics` so a missing entry makes the board's `metrics`
`null`; the view already prints `—` for an unknown number. Pin that, and pin the related
"unknown open count is `—`, a real zero is `0`" rule so it cannot drift back to `0`.

Part of #1265 (step 4, multi-repository). Fixes #1293.
@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Oct 26, 2026
@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at e3ded8e
Written-by: Reviewer
Attestation: e3ded8e role=Reviewer

The INC at e9ad1c6 (6416122171) is closed at this tip

  1. The whole entry list reaches mergeMetrics. combineRepos passes entries.map(entry => entry.metrics ?? null) — every repository, the unreadable ones included, with no .filter(Boolean) anywhere on that path (core/src/host/delivery-board.js:886; the two remaining filter(Boolean) calls are asOf/since stamps at :855-856 and label names at :89). mergeMetrics returns null as soon as one entry is null/undefined (:806-807), so a board whose repository did not answer has no total to print.
  2. The view prints —, not the readable half. With metrics: null the header falls back to a payload that carries no today/week/open (core/src/client/delivery-view.js:317), metricRows' count maps undefined to — (:163) and drops the CI line to "CI not available this time" (:184), and openCount maps null/undefined to — while a real 0 stays 0 (:328, :347-348).
  3. Both rules are pinned, not merely exercised. core/test/delivery-board.test.js:1081-1087 asserts partial.metrics === null, that the readable repository keeps its own numbers (partial.repos[0].metrics.today.newIssues === 1), and that a board where every repository read still sums (mixed.metrics.open.issues === 2). core/test/delivery-view.test.js:594-615 asserts Open: 0 issues for a real zero and Open: — issues (and not Open: 0 issues) for metrics: null. The earlier version had the partial case without these assertions; this one would go red on the defect I reported.

What I ran at this tip (myself, not from the body)

  • Seven knives, each cut on this tree and restored with cp (cmp clean and the suite green after every restore):
cut red counts
resolveRepos returns configured.slice(0, 1) not ok 45, not ok 46 (delivery-board) 46 pass / 2 fail
payload.repos.length > 1 → > 0 (single repository wrapped like several; fed repos: [one]) not ok 24 (delivery-view) 25 pass / 1 fail
withBoard.length < entries.length ? 'partial' → 'failed' not ok 46, not ok 48 46 pass / 2 fail
if (state === 'failed' || !hasBoard) → && not ok 10, 17, 18, 26 22 pass / 4 fail
stagesOf early return DELIVERY_COLUMNS not ok 23 25 pass / 1 fail
the INC fix removed (mergeMetrics(… .filter(Boolean)) + the whole-list null check dropped) not ok 48 47 pass / 1 fail
openCount back to String(value ?? 0) not ok 21 (an unknown open count is —, a real zero is 0 (#1297 INC)) 25 pass / 1 fail
  • 129/129 across 11 suites at this tip: delivery-board, delivery-view, delivery-repo, delivery-chart, delivery-write-failure, kanban, board-card, home-lock, i18n, client-bundle, client-apply-order.
  • patches/ds-bot/files/ mirrors are byte-identical to core/ for all eight carried files (src/host/delivery-board.js, src/host/delivery-repo.js, src/client/delivery-view.js, the three delivery test files, test/kanban.test.js, scripts/delivery-probe.mjs); ds-bot-patches check gives skipped pin (no git) / ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/; build-client --check exits 0; changelog: 16 fragment(s); mojibake: ok — 0 occurrences of U+FFFD in 1120 text file(s).
  • Head e3ded8e4, base ef5dae42; the body's Tip SHA and the checkpoint's checks.at both read e3ded8e4; pr: 1297, issue: 1293, claim: Dev4, Owner: Dev4, Reviewer: Reviewer, Fixes #1293, Part of #1265 (step 4 of 4). CI is 13 runs, 12 success (tests-ok finished 12:47:53Z); the only red is enqueue-guard, which refuses on the old-tip INC 6416122171 naming e9ad1c6a — expected before this comment, and it will re-run on it.
  • I also read the rest of the diff for its own defects: the per-repository boards, resolveNow's warning per default, the probe aggregation and the CLI's repeatable --repo all behave as the body says; the one-repository path returns the old payload unchanged (entries.length === 1), which knife 2 and an unknown open count is —… both pin.

Not run here

  • arch-docs: this box has no git, so check-arch-docs.mjs answers cannot diff against origin/main; the tip's package and Node 24 checks are green, which is where lint and arch-docs run.
  • The whole core suite (npm test) and plugins/*: out of this box's budget, CI runs them; the 11 suites above are the ones this diff reaches.
  • A live instance with two real repositories: the path is driven by the host's own views in delivery-board.test.js and the DOM tests in delivery-view.test.js.

Non-blocking, follow-ups only

  • core/src/host/delivery-board.js:861-863 builds the union columns from the hardcoded DELIVERY_COLUMNS, so a column id a repository carries but this build does not label stays out of the top-level union and its counts (each block still draws its own through stagesOf). That is The workflow engine: each node names its Bot and its inputs and outputs, the nodes are drawn as a flowchart, and a node missing an input is refused on the card #1272's seam.
  • core/src/client/delivery-view.js:422-433: when every repository failed, the page draws the first repository's error only — the other names' data-state are not shown, because the per-block layout needs a board. Not a regression (a single repository behaved this way), but a multi-repository instance cannot see which of its two names failed without pressing Diagnose.
  • core/src/client/delivery-view.js:279: probeText with a no-repository report prints 0 repositories; that report is not reachable from the Diagnose button (the header is not drawn in the failed/no-repo state), so it is latent text rather than a wrong screen.
  • core/src/host/delivery-board.js:950-953 reads the repositories one after another; with wait: true a two-repository test pays the sum of both rounds. The browser never passes wait, so this is latency in diagnostics, not in the page.
  • The body's ## Red without the source change line (:24-25) is broken by a heading and loses its closing backtick and parenthesis — format only, the knives themselves are all there.

Ran:

$ cd core && TZ=UTC DSH_HARNESS=/data/dsh/bot/workspace/p759/hrc2 node --test --import ./test/harness-resolve.mjs --test-concurrency=1 test/delivery-board.test.js test/delivery-view.test.js test/delivery-repo.test.js test/delivery-chart.test.js test/delivery-write-failure.test.js test/kanban.test.js test/board-card.test.js test/home-lock.test.js test/i18n.test.js test/client-bundle.test.js test/client-apply-order.test.js
# tests 129 / # pass 129 / # fail 0
$ (resolveRepos → configured.slice(0,1)) node --test … test/delivery-board.test.js
# tests 48 / # pass 46 / # fail 2   (not ok 45, 46)
$ (withBoard.length < entries.length ? 'failed') node --test … test/delivery-board.test.js
# tests 48 / # pass 46 / # fail 2   (not ok 46, 48)
$ (INC fix removed: mergeMetrics(...).filter(Boolean), null check dropped) node --test … test/delivery-board.test.js
# tests 48 / # pass 47 / # fail 1   (not ok 48)
$ (payload.repos.length > 0) node --test … test/delivery-view.test.js
# tests 26 / # pass 25 / # fail 1   (not ok 24)
$ (state === 'failed' && hasBoard) node --test … test/delivery-view.test.js
# tests 26 / # pass 22 / # fail 4   (not ok 10, 17, 18, 26)
$ (stagesOf early return DELIVERY_COLUMNS) node --test … test/delivery-view.test.js
# tests 26 / # pass 25 / # fail 1   (not ok 23)
$ (openCount → String(value ?? 0)) node --test … test/delivery-view.test.js
# tests 26 / # pass 25 / # fail 1   (not ok 21)
$ node scripts/ds-bot-patches.mjs check
skipped pin (no git): the tree hash cannot be computed here
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
$ node core/scripts/build-client.mjs --check   # exit 0
$ node .github/scripts/check-mojibake.mjs --check
mojibake: ok — 0 occurrences of U+FFFD in 1120 text file(s)

@Birfy Birfy added the pass label Oct 26, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit e37f1f8 Oct 26, 2026
13 of 14 checks passed
@Birfy
Birfy deleted the dev4/1265-multi-repo branch October 26, 2026 12:59
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 26, 2026
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…hinkFlowLab#1252) (ThinkFlowLab#1289)

fix(board): archive finished task cards and complete the overall card
(ThinkFlowLab#1252)

Written-by: Dev-1
Owner: Dev-1
Reviewer: Reviewer
Fixes ThinkFlowLab#1252
Tip SHA: `5b832b91f8ebdb1b7de782e87d40f35b24f6549d`

## What changed

- `core/src/host/tasks.js`: the `tasks` read lists active work only — a
card that is `accepted`, `cancelled` or `failed` leaves the list a board
polls. `{ archive: true }` is the same read including those rows; `{
archive: true, limit: N }` returns the newest N **finished** rows by
`updatedAt` (tiebreak `createdAt`) and no active rows, so the board's 15
s poll carries the slice and not the history while the default read
keeps carrying the active work. `limit` is a number or nothing: a
string, boolean or object is refused (`bot/invalid`) instead of coerced.
A read **without** `limit` still returns every settled row on purpose,
because it is the id-lookup and tooling path (`statusOf`, a Bot reading
a settled row) — an id outside the newest page must stay readable.
- The same file: new `settleOverall(child)` completes an overall group
card when every child has ended, through the same
`transition`/`applyColumn` write a person's move uses. It completes
`accepted` (with `progress: 100`) when each child ended `accepted` or
`cancelled`, and `failed` when any child ended `failed` — a failed part
is not hidden behind a green overall card. It is called where a move
lands on `accepted` (`applyColumn`), from `task-accept-result`, from
`task-cancel`, and on the two other doors a child ends through —
`taskBotDeleted` (deleting a Bot fails its open cards) and `taskDecided`
(an owner's reject cancels the card that waited on the approval) — so
neither path can leave the overall card `working` behind a child that
already ended. An overall card already `blocked` or `result-ready` is
left to its owner.
- `core/src/client/kanban.js`: the board keeps an `archive` list from
its second read and sorts it newest first by `updatedAt` (tiebreak
`createdAt`) **before** cutting, renders `boardTasksFor(tasks, archive)`
— active work plus the twenty newest finished cards — and moves a
finished card off the active list at once in `replace`, before the next
poll. A **Bot** board names its Bot in that read (`tasks { botId,
archive: true, limit: ARCHIVE_SHOWN }`): the host spends the page's
budget before the board can filter by `ownerBotId`, so an unfiltered
page lets another Bot's newer cards empty this Done column. The default
read stays unfiltered on purpose — it is active work, a superset the
board filters locally without losing a card. Ordering by `createdAt`
would drop exactly the card a member just finished, which has an old
`createdAt` and a fresh `updatedAt`.
- `core/src/client/inbox.js`: ThinkFlowLab#1252 made the default read active-only,
but the Inbox is a second consumer that classifies finished and failed
cards (`inboxModel`: `failed` → Needs you, `accepted`/`cancelled` →
Done) and counts them in `needsCount`. The one shared `useTasks` hook
behind the Inbox row and the dialog now asks `tasks { archive: true }` —
the uncapped list — and publishes that, so those cards reach the badge
and both groups again. The board's bounded second read is untouched.
- `docs/subsystems/core-client.md`: the `kanban.js` row said the cap
twice in one line (once for the read, once for the column); that is now
one sentence that also says a Bot board names its Bot, and the archive
read without `limit` staying uncapped is said once.

## Red first

All of them were written before the fix and run red on the unfixed tree
(`node --import ./test/harness-resolve.mjs --test
--test-name-pattern="1252" test/bot-tasks.test.js`).

First round, the original ThinkFlowLab#1252 behaviour:

```
a done card leaves the room board by default

true !== false
  expected: false
  actual: true
```

```
the overall card completes with its last child

'open' !== 'accepted'
  expected: 'accepted'
  actual: 'open'
```

Second round, after Security's INC on the previous tip: the failed
child, the unbounded payload, and the oldest-first slice.

```
a failed child makes the overall card fail, not pass

+ 'accepted'
- 'failed'
```

```
the requested limit bounds the payload

200 !== 20
  expected: 20
  actual: 200
```

```
the column starts at the newest finished card

+ 'task-done-19'
- 'task-done-36'
```

Third round, Security's second INC: a busy room spent the whole budget
on active rows.

```
the bounded archive carries finished rows only

false !== true
  expected: true
  actual: false
```

Fourth round, Reviewer2's INC on the previous tip: the two other doors a
child ends through, and the Bot-scoped archive page. Each knife was cut
on the `5385491f` tree, the file restored from a copy, and the pair
re-run green.

Knife 1 — delete `settleOverall(task)` from `taskBotDeleted`
(`core/src/host/tasks.js`), which reddens `ThinkFlowLab#1252: deleting a child's Bot
fails the overall card, read back from the archive`:

```
a failed child closes the overall card as failed

'open' !== 'failed'
  expected: 'failed'
  actual: 'open'
```

Knife 2 — put the Bot board's archive read back to the unfiltered
`query` (`core/src/client/kanban.js`), which reddens `ThinkFlowLab#1252: a Bot board
asks the host for that Bot's archive, not the whole scope`:

```
the archive read names the Bot
+ actual - expected

  {
    archive: true,
-   botId: 'a',
    limit: 20
  }
```

Restored: `bot-tasks.test.js` + `kanban.test.js` `20/20` green again.

Fifth round, Reviewer2's INC at `5385491f`: the Inbox is a second
consumer of `tasks`, and the active-only default cut its finished and
failed cards off. The drive posts `{ archive: true }` and asserts the
settled rows reach the badge and the two groups; on the unfixed tree the
read posted **no body at all**, so the fake host answered the
active-only default and every assertion fell over:

```
the Inbox read asks for the settled rows

expected: 1
  actual: 0
```

The same run, measured before the assertions: `posted null`, badge `"4"`
(the base count, unchanged), Done card `null`, Needs-you card `null`.

## Reviewer finding `6415648677` (`Verdict: INC at 86e9795`) — answered
at this tip

- **Item 1 — two terminal paths ended a child without settling the
overall card.** `settleOverall(task)` now runs directly after the
terminal `transition` in both `rt.taskDecided` branches (the
waiting-input branch and the blocked branch) and in `rt.taskBotDeleted`,
with the `continue` / `return save()` kept and no second gate added.
Proven by `ThinkFlowLab#1252: deleting a child's Bot fails the overall card, read
back from the archive` — child `failed`, parent `failed`, `columnOf`
done, and the progress the card already carried (the failed branch only
calls `transition`; `progress: 100` is set on the accepted branch alone)
— and by `ThinkFlowLab#1252: a rejected approval cancels the child and completes the
overall card, read back` — child `cancelled`, parent `accepted`,
`progress 100`, `columnOf` done. Both read the parent back through
`tasks { archive: true }` / `{ roomId, archive: true }`, not through a
return value.
- **Item 2 — the Bot-scoped page was cut before the `botId` filter.** A
Bot board now sends `archiveQuery = { botId: scope.botId }` with the
archive read: `{ botId, archive: true, limit: ARCHIVE_SHOWN }`; the
group board keeps `roomId`, and the default read stays unfiltered.
Proven by `ThinkFlowLab#1252: a Bot-scoped archive page is cut after the Bot filter`
(the Bot-scoped host page carries its own card while the unfiltered page
does not) and by the client drive that captures the posted body as
exactly `{ botId: 'a', archive: true, limit: ARCHIVE_SHOWN }`.
- **Non-blocking, docs.** The duplicated cap sentence in
`docs/subsystems/core-client.md` is collapsed to the sentence that
states the behaviour; the literal call syntax is gone.

## Reviewer finding `6416140014` (`Verdict: INC at 5385491`) — answered
at this tip

- **Blocking — the Inbox stopped receiving finished and failed cards.**
The chain at `5385491f`: `core/src/client/inbox.js` `useTasks` called
`actions?.tasks?.()` with no body, `core/src/client/index.js` sent it as
`{}`, and `core/src/host/tasks.js` answered the active-only default
(`archived ? rows : rows.filter(openTask)`), while `inboxModel`
classifies `failed` → Needs you and `accepted`/`cancelled` → Done and
`needsCount` counts them. Fixed in the one shared hook: `useTasks` now
sends `{ archive: true }` and publishes the list it gets; the board's
bounded second read is unchanged. Red at `5385491f` is quoted under *Red
first*, fifth round (`expected: 1, actual: 0` — no body posted; badge
unchanged at the snapshot count; both card lookups `null`). Green at
this tip: the same test passes, `core/test/inbox.test.js` **27/27**, and
the assertion is the behaviour — `failed` lands in Needs you, `accepted`
in Done, and the row badge counts the failed card.
- **Non-blocking — the bounded page is `settled`, the Done column keeps
only `accepted`.** Recorded as a decided residual under *Decisions
recorded, not omissions* below: newer `cancelled`/`failed` rows can
spend a scope's page budget and push an older Done card out of the
twenty. Not changed at this tip, and the reason is written down there.
- **Non-blocking — the Bot board's default read is still unfiltered
client-side.** That read is the **active** list: unfiltered is a
superset of the Bot's own rows and the board filters it in memory, so no
card can be lost — a host-side cut is what made the archive read need
`botId`, and that one sends it.
- **Non-blocking — the stale base line.** The old sentence naming a
`main` base that was only true at claim time is gone; the base history
below is the real path to this tip.


Advisory rounds: none at this tip yet — Chief freezes before a PASS
request. Superseded as evidence and kept as record: `6415907830` (Test)
and `6415965572` (Security) at `5385491f`; `6415574602` and `6415627981`
at `86e9795`; `6415419164` at `bdf867b2`; `6415205741` and `6415207539`
at `150aeaa`.

## Why these files, and the base

`main` moved several times while this was built: the branch was cut at
`99b1fab`, hand-re-applied onto ThinkFlowLab#1276's new card model (`taskCard` /
`BoardCardFrame`), then rebased onto `b4e1faa` (ThinkFlowLab#1284, which rewrote
`docs/subsystems/core-host.md`, a file in this diff), `63f81f0` (ThinkFlowLab#1278),
`ef5dae42` (ThinkFlowLab#1291) and now **`e37f1f83`** (ThinkFlowLab#1296 and ThinkFlowLab#1297, both landed
after `ef5dae42`). The tip is therefore the merged tree: current `main`
+ this diff. `ThinkFlowLab#1297`'s delivery-board test in `core/test/kanban.test.js`
was kept alongside this board's tests — the conflict was resolved by
keeping both sides — and the board text still sits in `### Bot task
board (ThinkFlowLab#1058)`, with no contradiction of ThinkFlowLab#1267's mid-turn-line
subsection. At this tip the pull request is **19 files, +1007/−104, one
commit** on base `e37f1f83`.

- `core/src/host/tasks.js` — the read contract, the bounded archive
read, the overall-card completion, and the settle on every door a child
ends through.
- `core/src/client/kanban.js` — the archive read (Bot-scoped on a Bot
board), newest-first ordering, the twenty-card column.
- `core/src/client/inbox.js` — the one shared task load asks for the
archive, so the Inbox keeps its Done and Needs-you cards.
- `core/client.js` — generated by `npm run build` in `core/`, never
edited by hand.
- `core/test/bot-tasks.test.js` — the eight ThinkFlowLab#1252 host cases
(active-only board and explicit archive; last child completes the
overall card; a failed child fails it; the requested archive bound with
the unbounded read still seeing every row; a busy room's bounded read
carrying finished rows only, with the `limit` type refusals; a deleted
Bot failing the overall card; a rejected approval cancelling the child
and completing the overall card; a Bot-scoped page cut after the Bot
filter) plus one compat read: the post-restart read of a finished card
now asks for the archive. This is where the active-only/archive case
lives, not `core/test/tasks.test.js`.
- `core/test/inbox.test.js` — the Inbox drive: the read posts `{
archive: true }`, a `failed` card lands in Needs you, an `accepted` card
lands in Done, and the row badge counts the failed card. The shared
harness keeps its no-op-effect stub for every other case; only this
drive runs its effect.
- `core/test/tasks.test.js` — only the `statusOf` helper changes: it
reads `{ archive: true }` (no limit) because it looks tasks up by id and
some are finished.
- `core/test/kanban.test.js` — the client fixture: 37 finished cards
give a literal twenty-card column starting at the newest, the oldest is
outside the slice, a card with an old `createdAt` and a fresh
`updatedAt` leads the column, and a driven load asserts the posted body
(Bot board `botId`, group `roomId`, default read unfiltered).
- `docs/subsystems/core-host.md` — the read contract with and without
`limit`, the bot/room filter before the cut, and when an overall card
completes accepted or failed (owned page for `tasks.js`).
- `docs/subsystems/core-client.md` — the board's two reads, the
newest-first cut, a Bot board naming its Bot, and the uncapped id-lookup
path; the duplicated cap sentence is merged (owned page for
`kanban.js`).
- `changelog.d/1252-board-archive.changed.md` — user-visible board and
Inbox change.
- `patches/ds-bot/0008-client-bundle.patch` and the
`patches/ds-bot/files/` mirrors — written by `node
scripts/ds-bot-patches.mjs generate` from that same `core/` tree; the
generator's context-only rewrite of `0005`, `0012` and `0014` was
reverted, and `check` re-run green.

## Decisions recorded, not omissions

- The board's archive read is capped at the same limit as the column (`{
...query, archive: true, limit: ARCHIVE_SHOWN }`, room-scoped for a
group, `{ botId }` for a Bot board, newest-settled first, finished rows
only — the default read carries the active ones), so the poll carries
the slice and not the history. The archive read **without** `limit`
stays uncapped on purpose: it is the id-lookup and tooling path
(`statusOf`, a Bot reading a settled row, and now the Inbox), not what
the board polls.
- The `counts` loop still runs over `[...own, ...archive]` unchanged,
and no counts assertion moved: the finished-only page is what stops a
card appearing in both lists and being counted twice.
- A child's requester can complete the room's overall card: the settle
path runs the same transition with no actor, so the card's status moves
and the requester gate is not consulted. It is status-only — nothing is
deleted, and the card's actors and history are kept.
- The bounded page is `settled`, while the Done column keeps only
`accepted`: twenty newer `cancelled`/`failed` rows in one scope can
spend the page's budget and push an older Done card out of the column.
Left as it is at this tip, deliberately: the page is the recent-activity
slice ordered by `updatedAt`, and narrowing it to `accepted` would make
a "newest finished" read lie about cancelled/failed work other callers
read. An older card is still readable without `limit`, which stays
uncapped on purpose.

## What I ran

At `5b832b91` (tree `f14bf9fd`, base `e37f1f83`).

- `TZ=UTC npm run verify` — exit 0: core **1784 tests, 1784 pass, 0
fail**, plugins/social 525, plugins/tree 125, plugins/timeline 125,
`.github/scripts` 509, interop 37, live-e2e 24; every suite green.
- the ThinkFlowLab#1252 suites on their own at the same tree:
`core/test/bot-tasks.test.js` 14/14, `core/test/kanban.test.js` 7/7,
`core/test/inbox.test.js` 27/27.
- `npm run build` in `core/` before `generate`; `node
scripts/ds-bot-patches.mjs generate` then `check` — `ok ds-bot/ =
Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)`, `ok 10 patches
apply cleanly in order`, `ok core/ equals ds-bot/ + patches/ds-bot/`.
The generator's context-only rewrite of `0005`, `0012` and `0014` was
reverted and `check` re-run green.
- `node core/scripts/build-client.mjs --check` inside `verify` — the
committed `core/client.js` matches `core/src/` at this tip.

## Not run

- The smoke bench (`core/dev/bench.sh smoke`): it needs a live DSH
instance and a free port; this touches `src/host/tasks.js`,
`src/client/kanban.js` and `src/client/inbox.js`, not `index.js`, and
the host behaviour is covered by `core/test/bot-tasks.test.js`,
`core/test/tasks.test.js`, `core/test/inbox.test.js` and
`core/test/task-drive.test.js`.
- Packaging, deploy and the desktop app — untouched.

## Verification at the tip

Everything above ran at `5b832b91f8ebdb1b7de782e87d40f35b24f6549d` — the
tip this pull request pushes, tree
`f14bf9fd5a0e75746d39c4168f57cb0ad1b719b2`, base
`e37f1f838023ce81e673d8fbec66a55d81dfe5e3` (19 files, +1007/−104, one
commit).

```
ok   ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
```

## Checkpoint

The generated mirrors —
`patches/ds-bot/files/src/{client/inbox.js,client/kanban.js,host/tasks.js}`
and `patches/ds-bot/files/test/{bot-tasks,inbox,kanban,tasks}.test.js` —
are copies written by the generator from the files above, so the block
lists the 12 paths a reviewer should open first; the diff itself is the
full 19.

<!-- flybot:checkpoint -->
```
{
  "pr": 1289,
  "claim": "Dev-1",
  "issue": 1252,
  "summary": "A finished card no longer ships with every board read: the tasks endpoint lists active work, { archive: true } adds the finished rows, and { archive: true, limit: N } returns the newest N finished rows, so the board's poll carries twenty settled cards and no active ones. A Bot board names its Bot there, and the Inbox asks for the archive so its Done and Needs-you cards return. The overall group card completes when every child has ended — accepted if each ended accepted or cancelled, failed if any failed. Tests were red first; docs, the client bundle and the patches ride in the same commit.",
  "files": [
    "changelog.d/1252-board-archive.changed.md",
    "core/client.js",
    "core/src/client/inbox.js",
    "core/src/client/kanban.js",
    "core/src/host/tasks.js",
    "core/test/bot-tasks.test.js",
    "core/test/inbox.test.js",
    "core/test/kanban.test.js",
    "docs/subsystems/core-client.md",
    "docs/subsystems/core-host.md",
    "patches/ds-bot/0008-client-bundle.patch",
    "patches/ds-bot/files/src/host/tasks.js"
  ],
  "checks": {
    "ran": "TZ=UTC npm run verify; node scripts/ds-bot-patches.mjs check",
    "result": "green",
    "at": "5b832b91f8ebdb1b7de782e87d40f35b24f6549d"
  },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One board, step 4: more than one repository

1 participant