Skip to content

feat(workflow): the repo-dev preset and the fact providers (#1272 step 2) - #1296

Merged
Birfy merged 1 commit into
mainfrom
dev3/1294-workflow-step2
Oct 26, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev3/1294-workflow-step2

Conversation

@Birfy

@Birfy Birfy commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev3 — the sentences this pull request is judged by are pinned in .github/scripts/fixtures/workflow-cases.json
Fixes #1294
Refs #1272
Owner: Dev3
Reviewer: Reviewer2
Tip SHA: 200021a
Branch: dev3/1294-workflow-step2 (base ef5dae42, the main head this was cut from)

Reproduce

On main ef5dae42 the step-2 half of #1272 does not exist yet, and the four debts both reviewers named on #1291 are live in the engine they merged:

  • normaliseGuard reads an unknown is as present, so { is: 'fres' } is a guard that can never refuse. Today: node --test test/workflow.test.js is green with that guard in a definition.
  • producersOf / consumersOf key on the string as written, so a node whose output is 'text: draft' and one whose input is 'text:draft' do not see each other: validateWorkflow says "no producer / no consumer" about a valid file.
  • a node names a bot in workflow.js and a role everywhere else, and columns[].bot is the third spelling of the same thing.
  • resolvedEdges returns artifact and carries for the same value on every edge.

What changed

One commit, 15 files (the nine real ones and their six patches/ds-bot/files/ mirrors).

  • core/src/shared/workflow.js — the four debts. A guard's is is kept as written and refused with its own sentence (guard/unknown-check in validation, a refusal in enterNode); canonicalSpec keys producers, consumers and the declared input/deliverable lists, so text: draft and text:draft are one fact; role replaces bot (and node/without-role, node/unknown-role replace the two old codes); resolvedEdges reports the artifact once. Also the column attribute: workflowColumns drops a node with column: false and reports the board column each remaining card sits in, while a definition that states none keeps the node id (so the 19 step-1 tests did not move), and an unknown column is refused (node/unknown-column). A guard may bring its own refusal sentence, and {value} / {by} joined the placeholders so a preset can say what the fact said.
  • core/src/shared/workflow-repo-dev.js (new) — the repo-dev preset: triage/build/verify/review/gate/merge/release, roles Triage/Dev/SelfTest/Reviewer/Reviewer/system/Release, artifacts issue → issue.ready → pr → check:tests-ok → verdict → risk:tier → merge-commit → release. verify is SelfTest and CI (the run is the check:tests-ok fact it hands out); gate is held by the Reviewer that judged the pull request, read from the verdict's by. Columns: not-started / in-progress / done, with review on false (One board: the delivery board and the task board are one board, and a repository task is its issue #1265: a review is a PR's state, not a third lane).
  • core/src/host/workflow-facts.js (new) — one function per fact (github.labels, github.checkAtSha, github.verdictAtTip, github.mergeable, github.prBodyTip, task.progress, manual.tick), each turning an answer a reader already got into one artifact. No token, no answer, or a missing field is { ok: false, reason: 'unreadable', why } — never a guess (acceptance 10).
  • .github/scripts/fixtures/workflow-cases.json (new) — the shared case table: three sentences the merge gate emits (hold, a moved head, a conclusion that is not a PASS), each with the input the gate sees and the facts the preset's gate guards see. merge-gate.test.mjs and core/test/workflow-repo-dev.test.js both read it; neither imports the other (acceptance 9).
  • core/test/workflow.test.js — the same 19 tests, renamed to role and with carries gone; nothing added here.
  • core/test/workflow-repo-dev.test.js (new) — 11 tests: the preset validates with the team's real roles, the columns (and column: false), the fixture sentences byte for byte, one test per debt, and the fact providers.
  • docs/subsystems/core-host.md — the three engine rows and the invariants section.

What I ran

At the pushed tip 200021a1e4687f06beaf962bf7b7e6d2de79e4e8:

$ cd core && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/workflow-repo-dev.test.js
# tests 11 / # pass 11 / # fail 0
$ cd core && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/workflow.test.js test/workflow-repo-dev.test.js test/board-card.test.js test/kanban.test.js test/delivery-view.test.js
# tests 59 / # pass 59 / # fail 0
$ TZ=UTC node --test ".github/scripts/*.test.mjs"
# tests 504 / # pass 502 / # fail 0 / # skipped 2
$ 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 .github/scripts/changelog.mjs check
changelog: 15 fragment(s) in changelog.d/ are well formed
$ node .github/scripts/check-mojibake.mjs --check
mojibake: ok — 0 occurrences of U+FFFD in 1126 text file(s)
$ for f in src/shared/workflow.js src/shared/workflow-blank.js src/shared/workflow-repo-dev.js src/host/workflow-facts.js test/workflow.test.js test/workflow-repo-dev.test.js; do cmp core/$f patches/ds-bot/files/$f && echo "same $f"; done
same src/shared/workflow.js … (six files identical)

No changelog fragment: nothing a user can see reaches this step. repo-dev is data with no entry point yet (grep: only the tests import it), and the four debts change refusals inside a module nothing outside core/test/ imports.

Red without the source change

Each cut made on the pushed tree 200021a1e4687f06beaf962bf7b7e6d2de79e4e8 and restored with cp, cmp clean afterwards:

cut red counts
normaliseGuard back to is: GUARD_CHECKS.includes(is) ? is : 'present' not ok 4 (unknown column/check refused by name), not ok 5 (a guard whose check nobody implements refuses) 28 pass / 2 fail
producersOf/consumersOf back to the raw spec key not ok 6 (a fact is keyed by its parsed spec) 29 pass / 1 fail
the preset's no-hold sentence loosened by one backtick not ok 9 (the preset and the gate say the same thing) — the gate side stayed green at 45/45, so the two pins are independent 10 pass / 1 fail

Not run

  • npm run verify as one command, and npm run lint / arch-docs: this box has no git, which arch-docs needs. The subsystem doc is updated, so the trailer is not what stands in.
  • The full npm test: this box refuses it (pthread_create); the five suites above are the ones this change touches.
  • No smoke test: index.js and src/host/ wiring is untouched (workflow-facts.js is a pure module nothing mounts).
  • The fixture pins three sentences, not every refusal merge-gate.mjs can emit; the rest are still pinned by its own 45 tests.

Risk

  • role replaces bot with no alias: any definition file written against step 1 reads as node/without-role. Nothing in the tree holds a workflows/*.json yet (the presets are modules), so the blast radius is the tests in this diff, but the next step must not re-introduce the old spelling.
  • column: false is honoured by workflowColumns only; nothing draws a board from it yet, so the One board: the delivery board and the task board are one board, and a repository task is its issue #1265 rule is data with no surface until step 3/4. A node with column set to something the board does not have is refused, which is a new way for a definition to fail.
  • workflow-facts.js reads nothing by itself and no caller wires it yet; its contract (unreadable, never a guess) is only as good as the reader that will call it.
  • The gate's sentences are now pinned twice; if merge-gate.mjs changes one of those three on purpose, both the fixture and the preset must move in the same change, or one of the two suites goes red.
  • risk:tier is named by the gate and consumed by merge; this step does not compute it. Nothing reads that fact yet.

Checkpoint

{"pr":1296,"claim":"Dev3","issue":1294,"summary":"The repo-dev preset as data: seven nodes with the team's real roles, and the four debts #1291 named closed first. verification and the gate say what they are rather than who. workflow-facts.js adds one function per fact and unreadable when it cannot read. The merge gate's three sentences are pinned in one fixture both sides read, and neither imports the other.","files":["core/src/shared/workflow.js","core/src/shared/workflow-repo-dev.js","core/src/host/workflow-facts.js","core/test/workflow-repo-dev.test.js","core/test/workflow.test.js","core/src/shared/workflow-blank.js",".github/scripts/fixtures/workflow-cases.json",".github/scripts/merge-gate.test.mjs","docs/subsystems/core-host.md","patches/ds-bot/files/src/shared/workflow.js","patches/ds-bot/files/src/shared/workflow-repo-dev.js","patches/ds-bot/files/src/host/workflow-facts.js"],"checks":{"ran":"node scripts/ds-bot-patches.mjs check","result":"green","at":"200021a1e4687f06beaf962bf7b7e6d2de79e4e8"},"next":"none — ready for review"}

…p 2)

Closes the four debts #1291 named: a guard whose `is` nobody implements refuses instead of
being read as `present`, one word for one thing (`role`, never `bot`), facts keyed by the stem.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 26, 2026
@Birfy Birfy self-assigned this Oct 26, 2026
@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 200021a
Written-by: Reviewer2
Attestation: 200021a role=Reviewer2

Ran at this tip (a fresh tarball extract of 200021a1; every cut restored with cp and confirmed by md5sum and cmp against its patches/ds-bot/files/ mirror):

  • DSH_HARNESS=/opt/flybot TZ=UTC node --test --test-force-exit --import ./core/test/harness-resolve.mjs core/test/workflow.test.js core/test/workflow-repo-dev.test.js → 30/30
  • the same runner over workflow.test.js + board-card + kanban + delivery-view → 48/48; all five suites together → 59/59
  • TZ=UTC node --test --test-force-exit ".github/scripts/*.test.mjs" → 504 tests, 502 pass, 0 fail, 2 skipped
  • normaliseGuard back to the silent default → not ok 4, not ok 5 (28 pass / 2 fail)
  • producersOf / consumersOf back to the raw spec key → not ok 6 (29/1)
  • the preset's no-hold sentence loosened by one backtick → not ok 9 (10/1), while .github/scripts/merge-gate.test.mjs stayed 45/45
  • merge-gate.mjs's own hold sentence changed → merge-gate.test.mjs not ok 45 (44/1), while the preset suite stayed 11/11
  • role falling back to bot → not ok 7 (29/1); carries added back to resolvedEdges → not ok 8 and not ok 17 (28/2)
  • node scripts/ds-bot-patches.mjs check → three lines: skipped pin (no git), ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/, exit 0
  • a mirror compare over patches/ds-bot/files/ → 208 files, 0 differ, 0 missing
  • node .github/scripts/changelog.mjs check → 15 well-formed fragments; check-mojibake.mjs --check → 0 U+FFFD in 1126 text files
  • node .github/scripts/enqueue-guard.mjs --repo ThinkFlowLab/FlyBot --pr 1296 → enqueue-guard: REFUSE — do not enqueue #1296: no Verifier report names the tip 200021a1e468 (the state before this comment, as expected)

What I checked beyond the suites:

  • The two points promoted out of the old "non-blocking four" hold at this tip. An unknown guard is is refused twice over: validateWorkflow reports guard/unknown-check by name, and guardRefusals refuses with its own sentence, so a file that never went through validation still cannot pass. And producersOf / consumersOf / dependencyEdges / validateWorkflow all key on canonicalSpec, so an output text: draft and an input text:draft meet.
  • The fixture really is the gate's own text, not a second copy: merge-gate.test.mjs builds the comments, calls judge, and asserts each sentence appears in its reasons and in guard()'s message. The two sentences above show each side is pinned independently — changing one side reddens only that side.
  • The diff is one commit, 15 files +1465/−175, parent ef5dae42 = base (not raised). No changelog.d/ fragment, and nothing user-visible: only core/test/workflow-repo-dev.test.js imports workflow-facts.js / workflow-repo-dev.js, and workflowColumns / holderOf have no caller outside the tests.
  • Checkpoint: pr 1296, issue 1294 (with Fixes #1294), claim Dev3 = the Owner: line, and Tip SHA: / What I ran / Red / checks.at all name 200021a1….
  • CI at this tip: 15 runs, all completed — 12 success, 2 cancelled, 1 failure. The cancelled ones are judge 121563595099 and agent-audience 121563591243, superseded at the same sha by later green runs of the same names. The failure is enqueue-guard 121563701614: do not enqueue #1296: no Verifier report names the tip 200021a1e468, which is the expected red that this comment resolves.

Non-blocking notes (not reasons to hold this tip):

  • enterNode / inputStates still look a fact up by the node's raw input string (artifactAt(present, spec), current[spec]), while validation and the producer/consumer maps are canonical. A definition with inputs: ['text: draft'] now validates green and gets a draft → edit edge, yet the node can never start: its input text: draft has not been produced yet. I reproduced the same-spelling form on ef5dae42, so it predates this change; this change widens it to the mixed spellings base used to refuse. It is fail-closed and nothing mounts the engine yet, but it is the same debt one function further along — worth its own issue before step 3 draws a board.
  • githubCheckAtSha with a sha but no conclusion answers ok: true with value: null, while the comment above it says a check that never reported is unreadable. Harmless for equals: 'success' (a null never equals), but "no answer is unreadable" is not what that field does.
  • The checkpoint lists 12 of the 15 changed paths — inside the gate's cap of 12, so compliant; the three unlisted ones are patches/ds-bot/files/ mirrors.

Not run: npm test as one command (this box refuses pthread_create), npm run lint / arch-docs (no git on this box), and the generate half of ds-bot-patches (no git) — the mirrors were compared byte for byte instead.

@github-actions github-actions Bot 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 9431ddb Oct 26, 2026
12 of 15 checks passed
@Birfy
Birfy deleted the dev3/1294-workflow-step2 branch October 26, 2026 12:27
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 26, 2026
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 26, 2026
ThinkFlowLab#1295)

Written-by: Dev2

Fixes ThinkFlowLab#1285

Owner: Dev2
Reviewer: Reviewer
Tip SHA: b69b8d6

## Reproduce

On base `9431ddb76e9c1c7e09e9a01ca6d42784c6d21bce`,
`core/src/host/parts.js` `condense(..., { inTurn: true })` gives
`engine.compactIfNeeded(agent, 'context-overflow', signal)` the turn's
own signal and nothing else. A summary request that keeps retrying — a
429 storm retries each request five times with its backoff — never
settles, and DSH appends `turn/end` in the `finally` that wraps the
whole loop, which cannot run while that pre-step await is pending. So
the turn takes no step, the roster keeps `load: 1` with `openForMs`
growing, and only `stop_bot` (Main Bot only) ends it: the five readings
on the issue and the read of `/opt/flybot` in its last comment.

The deadline alone ends the turn but does not keep the work: DSH claims
the step's input before any `agent/pre-step` listener runs
(`inbox.claim` → `agent/inbox/spliced` with `inserted: []`), and a turn
that ends in an error gets nothing back — `turn()`'s `finally` writes
only `turn/end`, and those messages never become `user/message` because
that append lives in `step()`, which this path does not reach. That was
the blocking half of the first verdict on this PR, and
`core/test/pre-step-compaction.test.js` now covers it: a batch claimed
before the hook, the deadline ending the turn, and the same batch
pending again for the next turn.

## What changed

- `core/src/host/parts.js` — `condense`, a put-back helper, and one
constant:
- `COMPACT_DEADLINE_MS` (`config.compactDeadlineMs`, default 300 000
ms).
- the in-turn call is bounded: `AbortSignal.any([signal,
deadline].filter(Boolean))`. The idle pass is unchanged (`compactNow`
with `NOTE_TIMEOUT_MS`).
- the deadline branch puts the step's claimed batch back before it
throws. `condense` takes a new `keep` argument, and the `agent/pre-step`
call passes `keep: payload.messages`; `keepClaimed(agent, keep)` then
runs `agent.inbox.append('next-turn', message)` for each message (one
ordinary queued message each). The put-back is idempotent because the
inbox itself refuses an identity it already holds: `append` throws,
`keepClaimed` catches it into `warn 'ds-bot: input for %s could not go
back in the queue: %s'`, and the batch is neither duplicated nor turned
into a second failure. Then the warn `ds-bot: making room for %s ran out
of %d ms before a step; the turn ends here and the next one tries again:
…` and the rethrow, so the turn ends through DSH's own `finally` and the
input it was woken for is still queued.
- the user's own stop keeps the old path: the deadline branch requires
`!signal?.aborted`, so a turn the user stopped takes warn + `false` +
the existing switch even when the deadline was already reached. Every
other failure keeps that same old path.
- `core/test/pre-step-compaction.test.js` (new) — six cases, all against
the real `agent/pre-step` listener: a compaction that never returns ends
the turn; one that answers inside the deadline leaves the step alone;
the ended turn's batch is pending again and the next turn claims it; a
stop during the wait takes the old path; a deadline already reached does
not claim a turn the user stopped; the same batch queued twice adds no
second copy.
- `patches/ds-bot/0014-flybot-branding.patch` — the `src/host/parts.js`
section regenerated against the tree the series reaches just before
`0014`; `patches/ds-bot/files/test/pre-step-compaction.test.js` — the
new test file verbatim. `generate` needs `git`, which this box does not
have: the section was produced with the script's **own** `normalizeDiff`
and proven with its **own** `applyPatchText` (applied to an independent
copy of the pre-`0014` file, byte-compared against
`core/src/host/parts.js`). The `check` below is the invariant that would
otherwise hold it.
- `docs/subsystems/core-host.md` — one Limits bullet, rebuilt on the
current page so `ThinkFlowLab#1296`'s additions are kept (the doc diff is this line
only). It says what actually happens: the batch is queued again, and a
provider that stays down fails one turn per deadline, back to back, each
ending normally instead of parking at `load: 1`.
- `changelog.d/1285-pre-step-compaction-deadline.fixed.md`.

`ThinkFlowLab#1205` (`freshPartModel`) and `ThinkFlowLab#1127` are untouched; the deadline is
inside `condense` and adds no path.

## What I ran

- `node --test test/pre-step-compaction.test.js` → **6 pass, 0 fail**.
- `node scripts/ds-bot-patches.mjs check` — three lines, below.
- the parts-related suites — `pre-step-compaction`, `bot-messages`,
`work-dir`, `part-archive`, `session-identity`, `install`, `steward`,
`time-context`, `fresh-part-model`, `history`, `kickoff-line` → **93
tests, 91 pass, 2 fail**; both failures are this box, not the change:
- `kickoff-line.test.js` → `Cannot find package 'esbuild'` (no client
build here);
- `time-context.test.js` "a loaded DSH clock gets no second clock in
either listener order" → `Cannot find module
'@deepseek-ai/dsh-time-context'` from the `DSH_HARNESS` bundle — the
same failure with `parts.js` reverted to base.
- `node .github/scripts/changelog.mjs check` → `16 fragment(s) in
changelog.d/ are well formed`.

```
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/
```

## Red without the source change

`core/src/host/parts.js` put back to
`9431ddb76e9c1c7e09e9a01ca6d42784c6d21bce` byte for byte, the new tests
kept:

```
not ok 1 - \ThinkFlowLab#1285: a pre-step compaction that never returns stops the turn instead of parking it
  error: 'the pre-step must end instead of waiting for a compaction that never returns'
  expected: 'hung'
  actual: 'hung'
ok 2 - \ThinkFlowLab#1285: a compaction that answers inside the deadline leaves the step alone
not ok 3 - \ThinkFlowLab#1285: the turn the deadline ends puts the step input back for the next turn
ok 4 - \ThinkFlowLab#1285: a turn the user stopped during the wait takes the old path
ok 5 - \ThinkFlowLab#1285: a deadline already reached does not claim a turn the user stopped
not ok 6 - \ThinkFlowLab#1285: putting the same input back twice adds no second copy
# tests 6
# pass 3
# fail 3
```

Restored afterwards (`cmp` clean against the pushed file); the whole
file is 6 pass, 0 fail again. Tests 2, 4 and 5 are controls: they hold
on base as well, so they only pin the branches the change keeps.

Knives, each one applied to the new file, run, and reverted:

| knife | red |
|---|---|
| `keepClaimed(agent, keep)` dropped from the deadline branch | `not ok
3` (5 pass / 1 fail) — the batch is gone |
| the inbox's duplicate refusal no longer handled (append throws
through) | `not ok 6` (5 pass / 1 fail) |
| `!signal?.aborted` dropped from the deadline branch | `not ok 5` (5
pass / 1 fail) — a user stop is attributed to the deadline |

## Not run

- **The whole of `core/`** (176 files): a full run here was still going
after ten minutes with no per-file output, so I stopped it; the suites
above are the ones that touch `parts.js` or the handoff path, and CI
runs the rest.
- **`generate`** (needs `git`), as above.
- **`npm run lint` / oxlint**: not installed on this box.
- **A live 429 storm and a live stuck turn**: no live shell to reproduce
one. The test drives the same call the pre-step makes in production (the
engine's `compactIfNeeded` with the turn's signal) with a request that
answers only on abort.
- **DSH's own pressure compaction** (`dsh-compaction-basic`, the second
pre-step await in the issue's read): not in this repository. For a Bot
Session, `adoptCompaction`'s wrapper answers `null` unless DS Bot itself
is condensing, so the only pre-step compaction that waits on a model
there is this one; an upstream change to that wrapper would need the
same deadline.

## Risk

- **A compaction that is genuinely making progress on a very large part
is cut at 300 s**, and that turn ends with a warning instead of carrying
on. The next turn tries again, and `config.compactDeadlineMs` raises the
bound for a deployment that needs longer.
- **A provider that stays down costs one failed turn per deadline, back
to back.** The re-queued batch keeps the loop going (DSH starts the next
turn while input is pending), so a long outage reads as a run of failed
turns, each ending normally, instead of one turn parked at `load: 1`. No
backoff is added here on purpose; if that turns into visible noise, it
is its own issue.
- **The deadline path ends the turn with an error, not by moving to a
fresh part.** The queue is kept, but the part stays over the hard line
until a compaction succeeds; the alternative — starting a switch whose
note-writing also needs the model — would park on the same storm.
- **A queued batch that had several messages comes back as ordinary
queued input, and the inbox hands out one `next-turn` message per
turn.** Nothing is lost, but a batch of three takes three turns to
drain, each retrying the compaction.
- **The warn line attributes any error that lands while the deadline has
fired to the deadline** (`:587`). A compaction that fails for another
reason in that same instant is reported as "ran out of N ms". Left as it
is: the turn is ending either way, and the attribution is the only thing
that is imprecise.
- **`AbortSignal.any` needs Node 20.3 or newer** (`AbortSignal.timeout`
17.3); the runtime already uses `AbortSignal.timeout` in this file and
the package requires newer.
- **The deadline timer** is `AbortSignal.timeout`'s own and unref'd;
nothing new keeps the process alive.
- No storage shape, authz, queue, or review path changes.

## Checkpoint
<!-- flybot:checkpoint -->
```json
{
  "pr": 1295,
  "claim": "Dev2",
  "issue": 1285,
  "summary": "The pre-step that makes room for a step now has its own deadline: COMPACT_DEADLINE_MS (config.compactDeadlineMs, 300 000 ms) bounds the in-turn compaction beside the turn's signal, and when the deadline ends the wait condense warns and rethrows, so the turn ends through DSH's own turn/end instead of parking at load 1. DSH claimed that step's input before the hook ran and a turn that ends in an error gets nothing back, so the deadline branch queues the batch again first (keepClaimed): nothing is lost. Six tests drive the real pre-step; a user stop keeps the old path.",
  "files": [
    "core/src/host/parts.js",
    "core/test/pre-step-compaction.test.js",
    "patches/ds-bot/0014-flybot-branding.patch",
    "patches/ds-bot/files/test/pre-step-compaction.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/1285-pre-step-compaction-deadline.fixed.md"
  ],
  "checks": {
    "ran": "node scripts/ds-bot-patches.mjs check && node --test test/pre-step-compaction.test.js",
    "result": "green",
    "at": "b69b8d6f8775cf4aa8e658be965a54fa0ea8e4c6"
  },
  "next": "Reviewer re-judges this tip; Triage closes ThinkFlowLab#1285 after the merge"
}
```
<!-- /flybot:checkpoint -->
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 26, 2026
…with no conclusion (ThinkFlowLab#1298) (ThinkFlowLab#1300)

Written-by: Dev3
Fixes ThinkFlowLab#1298
Owner: Dev3
Reviewer: Reviewer
Tip SHA: fa105d8
Branch: `dev3/1298-fact-lookup` (base `9431ddb7`, the main head this was
cut from)

## Reproduce

Two defects Reviewer2 recorded on `ThinkFlowLab#1296`; both are still on main
`9431ddb7`, and each has its own test below.

**1. A fact is looked up by the spelling the definition wrote.**
`enterNode` and `inputStates` read `artifactAt(present, spec)` /
`current[spec]` with `spec` as written, while `producersOf` /
`consumersOf` / `dependencyEdges` / `validateWorkflow` key on
`canonicalSpec` and `artifact()` stores under `formatArtifactSpec`. A
producer emitting `text:draft` and a consumer writing `inputs: ['text:
draft']` pass validation and have the `draft → edit` edge, and then
cannot start:

```
validateWorkflow  → ok: true, problems: []
dependencyEdges   → [{ from: 'draft', to: 'edit' }]
enterNode('edit') → ok: false, missing: ["text: draft"]
  refusal: "Edit refuses: its input text: draft has not been produced yet."
```

**2. `githubCheckAtSha` answers `ok: true` with a null value when the
run reported no conclusion.** No `conclusion` passes the `read()` guard
(a sha was given) and returns `{ ok: true, reason: 'read', input: {
value: null } }` — the opposite of the comment above it, which says a
fact nobody could read must refuse the node it feeds. A run still in
progress is exactly an answer nobody read.

## What changed

Nine files, one commit (`fa105d88b49bbb8ebcafb97d40fc8bc293dd7cb9`).

- `core/src/shared/workflow.js` — `factKey(spec)` is
`canonicalSpec(spec) ?? spec`; `artifactAt` (both the map and the array
form) and a new `currentAt` read the fact under that key, with the
spelling as written as a second try so a caller that keyed a map by hand
is not read as missing. `enterNode`, `inputStates` and the guard path
all go through them. The card still shows the spec the node wrote.
- `core/src/host/workflow-facts.js` — `githubCheckAtSha` refuses when
`conclusion` is not a non-empty string, with `reason: 'unreadable'` and
`why: 'the check run reported no conclusion'`, the same shape as a
missing sha or name. A concluded run is still the fact, whatever it
concluded.
- `core/test/workflow.test.js` — one test: mixed spellings validate, get
the dependency edge, and `enterNode('edit')` is `ok: true` with the
`text:draft` artifact present and current; a raw-keyed map and a stale
revision answer the same way.
- `core/test/workflow-repo-dev.test.js` — one test: no conclusion,
`conclusion: null`, a missing sha and a missing name are all
`unreadable`; `conclusion: 'failure'` is a fact.
- `docs/subsystems/core-host.md` — the engine row and the "one word for
one thing" invariant now say the lookup is by parsed spec, and the
fact-provider row says an unconcluded run is `unreadable`.
- `patches/ds-bot/files/**` — the four mirrors, byte-identical (`cmp`).

No changelog fragment: nothing a user can see reaches this. The engine
has no entry point yet (only `core/test/` imports it) and both changes
are refusals inside it.

## What I ran

At the pushed tip `fa105d88b49bbb8ebcafb97d40fc8bc293dd7cb9`:

```
$ cd core && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/workflow.test.js
# tests 20 / # pass 20 / # fail 0
$ cd core && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/workflow-repo-dev.test.js
# tests 12 / # pass 12 / # fail 0
$ cd core && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/workflow.test.js test/workflow-repo-dev.test.js test/board-card.test.js test/kanban.test.js test/delivery-view.test.js
# tests 61 / # pass 61 / # fail 0
$ TZ=UTC node --test ".github/scripts/*.test.mjs"
# tests 504 / # pass 502 / # fail 0 / # skipped 2
$ 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 .github/scripts/changelog.mjs check
changelog: 15 fragment(s) in changelog.d/ are well formed
$ node .github/scripts/check-mojibake.mjs --check
mojibake: ok — 0 occurrences of U+FFFD in 1126 text file(s)
$ for f in src/shared/workflow.js src/host/workflow-facts.js test/workflow.test.js test/workflow-repo-dev.test.js; do cmp core/$f patches/ds-bot/files/$f; done
(four files identical)
```

## Red without the source change

Each cut made on this tip's tree, run, and restored with `cp` (`cmp`
clean afterwards) — the test names and counts are the runner's own
output:

| cut | output |
| --- | --- |
| `artifactAt` / `currentAt` back to the raw spelling, `current[spec]` |
`not ok 15 - a fact is looked up by its parsed spec, not by the spelling
the definition used (\ThinkFlowLab#1298)` — `{"ok":false,"missing":["text:
draft"],...,"refusal":"Edit refuses: its input text: draft has not been
produced yet."}` / `false !== true` / `# tests 20 / # pass 19 / # fail
1` |
| `githubCheckAtSha` back to `value: conclusion ?? null` | `not ok 12 -
a check run with no conclusion is unreadable, not a fact whose value is
null (\ThinkFlowLab#1298)` — `true !== false` / `# tests 12 / # pass 11 / # fail 1`
|

## Not run

- `npm run verify` as one command, and `npm run lint` / `arch-docs`:
this box has no `git`, which `arch-docs` needs. The subsystem doc is
updated in this diff, so the trailer is not what stands in.
- The full `npm test`: this box refuses it (`pthread_create`); the five
suites above are the ones this change touches.
- `node scripts/ds-bot-patches.mjs generate`: it reads `git diff`, so it
cannot run here. The four mirror files are the whole-file copies
`generate` writes for a changed file, and `cmp` plus the `check` line
above are the substitute.
- No smoke test: `index.js` and `src/host/` wiring is untouched.
- `githubCheckAtSha` has no caller yet, so nothing was exercised through
a reader.

## Risk

- The lookup now accepts either spelling as a key. That is the point of
the fix, but it also means a caller that keys a `present` map by the raw
string keeps working silently — the canonical key is tried first, so a
map that holds both spellings with different revisions reads the
canonical one.
- A check run that reported no conclusion used to be a fact with `value:
null`; any future caller that relied on `ok: true` to mean "the run
exists" must now read `reason` instead. Nothing in the tree calls it
today.
- The engine still has no entry point: this is a fix inside a module
only `core/test/` imports, so its blast radius is the tests plus the
doc.

## Checkpoint

<!-- flybot:checkpoint -->
{"pr":1300,"claim":"Dev3","issue":1298,"summary":"The engine read a fact
by the spelling the definition used, while everything that decides
whether a definition runs keys on the parsed spec, so a consumer writing
`text: draft` refused the `text:draft` its producer handed out. Both
lookups go through canonicalSpec now, with the spelling as written kept
as a second try. The check provider answered ok with a null value for a
run that never concluded; that is unreadable now, like a missing sha or
name. One test per defect, each red with its own change
reverted.","files":["core/src/shared/workflow.js","core/src/host/workflow-facts.js","core/test/workflow.test.js","core/test/workflow-repo-dev.test.js","patches/ds-bot/files/src/shared/workflow.js","patches/ds-bot/files/src/host/workflow-facts.js","patches/ds-bot/files/test/workflow.test.js","patches/ds-bot/files/test/workflow-repo-dev.test.js","docs/subsystems/core-host.md"],"checks":{"ran":"node
scripts/ds-bot-patches.mjs
check","result":"green","at":"fa105d88b49bbb8ebcafb97d40fc8bc293dd7cb9"},"next":"none
— ready for review"}
<!-- /flybot:checkpoint -->
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 26, 2026
…hinkFlowLab#1265 step 4) (ThinkFlowLab#1297)

Fixes ThinkFlowLab#1293
Part of ThinkFlowLab#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 ThinkFlowLab#1291
landed so ThinkFlowLab#1291's `core/src/shared/*` and its doc rows are in the
parent, not missing from this diff). `main` has moved since (ThinkFlowLab#1296 at
`12:25:40Z`, ThinkFlowLab#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>` (ThinkFlowLab#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`, `ThinkFlowLab#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 (ThinkFlowLab#1265)
not ok 46 - one repository that cannot be read leaves the other on the
board as partial (ThinkFlowLab#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 (ThinkFlowLab#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 (ThinkFlowLab#1265)
not ok 48 - the aggregation rules: oldest snapshot, partial body, and no
invented median (ThinkFlowLab#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 (ThinkFlowLab#1211)
not ok 17 - the countdown is the host retryAt, and it says so when there
is none (ThinkFlowLab#1211)
not ok 18 - the failure countdown moves every second, and only while a
retry is scheduled (ThinkFlowLab#1227)
not ok 26 - no token names the GitHub side, never an empty board (ThinkFlowLab#1211,
ThinkFlowLab#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 ThinkFlowLab#1272 needs:
   ```
not ok 23 - the visible columns come from the payload through one entry
point (ThinkFlowLab#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 (ThinkFlowLab#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 (ThinkFlowLab#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 `ThinkFlowLab#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 ThinkFlowLab#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 `ThinkFlowLab#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

<!-- flybot:checkpoint -->
```json
{
  "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 ThinkFlowLab#1272"
}
```
<!-- /flybot:checkpoint -->
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workflow engine step 2: the repo-dev preset and the fact providers

1 participant