Skip to content

fix(host): a line sent while a member speaks reaches it mid-turn (#1267) - #1284

Merged
Birfy merged 1 commit into
mainfrom
fix/1267-mid-turn-line-to-speaker
Oct 26, 2026
Merged

Birfy merged 1 commit into
mainfrom
fix/1267-mid-turn-line-to-speaker

Conversation

@Birfy

@Birfy Birfy commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev2

Fixes #1267

Owner: Dev2
Reviewer: Reviewer
Tip SHA: dd99d33

Reproduce

On base 0472c168, a line the owner sends into a group while a member is speaking does not reach that member's running turn, and is not in the room log until the current speaker finishes: runRound drains the room Session's inbox at its loop head only, and waitingLines reads the room Session's queue, not the speaking member's. So the speaking member's next model step never carries the line, and the room shows it only after that whole turn.

The three new tests are the reproduction, and they are red on base (see Red without the source change for the verbatim output):

  1. #1267: a line the user sends while a member speaks reaches that member at its next step, not after its turn — with the admin speaking, the fake member Session records no steer for the owner's line, the room log does not carry it, and the running round has no user reply for it; the test also pins that the speaker's own turn was not duplicated into a second slot.
  2. #1267: the line reaches the room once and leaves the relay queue behind — after the speaker is done, the relay's pending copy is gone, so the next relay neither logs the line a second time nor answers it again.
  3. #1267: a line that only says stop supersedes the speaking slot, and its answer is not posted — 停 marks the slot superseded, the stopped turn's text (Half done) reaches neither the log nor round.replies, a stopped reply takes its place, the line is not steered anywhere, and the head drain opens it once as a fresh line (one user reply, one log line).

What changed

  • core/src/host/groups.js — the whole change, in the room round:
    • ROOM_STOP: 停 / 停下 / 停止 / 别做了 / 不要做了 / stop / stop it (the words stop_bot is called with), ROOM_MID_LINE (the note that says the line arrived after the turn started) and ROOM_MID_LINE_POLL_MS (100).
    • speakMidLine: logs the line at once (appendRoom + notePostedLine), removes the relay's pending copy, pushes the user reply into the running round, resets lateRounds, and then either steers it to the speaker (rt.deliver(..., steer: true, note: ROOM_MID_LINE)) or — for a stop line — marks the slot superseded, pushes the stopped reply, and cancels exactly that exchange (participant.cancel({ sessionId, exchangeId })).
    • watchSpeakerLines: while a member's turn runs, polls the same queue the round drains (waitingLines(agent), nextStep/nextTurn) every 100ms; each new line is removed and handed to speakMidLine. A stop line leaves the queue alone (that is the only B path) and ends the watch.
    • runRound: wraps participant.runTurn in try { … } finally { stopWatching() }, and a superseded slot does not post (if (slot.status === 'superseded') continue).
    • drainRoomInbox: a line whose inbox id is in round.midLineIds was already shown when it arrived, so the head drain opens its fresh line without logging or replying with it a second time.
    • slotLabel: superseded renders as (stopped by the user), next to the existing stopped reply the client already draws.
  • core/test/rounds.test.js — the fake agent gains steer (recorded in a steered list kept apart from sent) and cancel; three tests above.
  • patches/ds-bot/0012-room-membership.patch, patches/ds-bot/0005-social-seams.patch — groups.js and rounds.test.js are edits to upstream files, so their hunks live in the topic patches. generate needs git, which this box does not have; the sections were produced with the script's own normalizeDiff and proven with its own applyPatchText (apply to ds-bot/<file>, byte-compare with core/<file>) before being spliced in. check below is the invariant generate would establish.
  • docs/subsystems/core-host.md — one new subsection, A line sent while a member speaks (A line sent into a group chat while a member is speaking must reach that member at its next step, not after its turn #1267); core/src/host/ is core-host's owned path, so the page follows the change.
  • changelog.d/1267-mid-turn-line.fixed.md.

#299 (fallbackDue) and #1205 (boardCoversCue) are untouched; the new path adds no slot, so it cannot widen a round.

What I ran

  • node scripts/ds-bot-patches.mjs check at the tip — three lines, below.
  • node --test test/rounds.test.js (env DSH_HARNESS=/data/har021a, --import ./test/harness-resolve.mjs) → 55 pass, 0 fail (52 before, +3).
  • node --test test/context-sharing.test.js test/room-membership.test.js → 27 pass, 0 fail.
  • node .github/scripts/changelog.mjs check → 11 fragment(s) in changelog.d/ are well formed.
  • node scripts/ds-bot-patches.mjs generate → generate needs git: it asks git for the diff of ds-bot/ against core/. Without git, edit core/ and patches/ds-bot/ together and run check.
  • The knife below.
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/

The pin line is skipped on this box (no git, so the tree hash cannot be computed); CI's package step is the reference for it. The two ok lines are this box's real run.

Red without the source change

core/src/host/groups.js put back to base 0472c168 byte for byte, the three tests kept:

not ok 1 - \#1267: a line the user sends while a member speaks reaches that member at its next step, not after its turn
  error: 'the line reaches the member that is speaking, not the next slot'
not ok 2 - \#1267: the line reaches the room once and leaves the relay queue behind
  error: 'the speaker has it'
not ok 3 - \#1267: a line that only says stop supersedes the speaking slot, and its answer is not posted
  error: 'the speaking slot is superseded'
# tests 3
# pass 0
# fail 3

Restored afterwards (cmp clean against the pushed file); back to 55 pass, 0 fail for the whole file.

Not run

  • The whole of core/: only the three suites above were run, one file per process. npm test over everything is left to CI.
  • npm run lint / oxlint / the arch-docs gate: this box has no oxlint and no git (the gate diffs against ARCH_DOCS_BASE), so neither can run here. The gate's rule is followed by hand: core/src/host/ is owned by core-host, and docs/subsystems/core-host.md carries the new subsection in this PR.
  • generate (no git), as above; the two patch sections were normalized and proven with the script's own functions, and check's second line is the equality proof.
  • A live DSH where the owner types while a Bot is visibly working: no live shell in this session. The queue the test writes into is the one waitingLines reads in production (sessionController.prompt with mode queue/steer), and the delivery is the existing rt.deliver steer path, so the test exercises the mechanism rather than a stand-in.

Risk

  • One 100ms timer per member turn that is speaking (setInterval, unref'd, cleared in finally). It reads two arrays of an in-memory inbox and does nothing when they are empty.
  • The relay's pending copy is dropped by text match (rt.roomInbox first non-Bot item with the same text), the same key notePostedLine already uses. Two identical lines typed within one poll window leave one copy in the queue, which the head drain then answers as a fresh line.
  • If the steer itself fails, the line is logged and in the round's replies but the speaker may not have seen it; the pending copy is already gone, so the round warns and nobody answers it. That is a delivery failure, not a silent drop — rt.warn names the member and the room.
  • A stop is recognised only by those exact words. 停一下, 停下吧 do not match and are delivered like any other line (shape A). That is deliberate: a word that only contains stop is not proof the person wants the work thrown away.
  • Steering reaches the member at its next model step, not in the middle of a running tool call; a turn whose next step is far away (a long bash) still answers late, but it does see the line and its header says when it arrived.
  • No authz, storage shape, review path or queue is touched: the change is inside runRound and its helpers.

Checkpoint

{
  "pr": 1284,
  "claim": "Dev2",
  "issue": 1267,
  "summary": "A line the owner sends into a group while a member speaks now reaches that member inside its turn: the room round polls the queue, logs the line at once, drops the relay's copy, and steers it into the speaker. A note says the line arrived after the turn started, so the member answers it in the next step instead of waiting for the slot to end. An explicit stop (停/stop/…) supersedes the speaking slot, cancels that exchange, and posts a stopped reply in place of the turn's text. The line stays queued and the head drain answers it once; three tests in core/test/rounds.test.js cover it.",
  "files": ["core/src/host/groups.js", "core/test/rounds.test.js", "patches/ds-bot/0012-room-membership.patch", "patches/ds-bot/0005-social-seams.patch", "docs/subsystems/core-host.md", "changelog.d/1267-mid-turn-line.fixed.md"],
  "checks": { "ran": "node scripts/ds-bot-patches.mjs check && node --test test/rounds.test.js test/context-sharing.test.js test/room-membership.test.js", "result": "green", "at": "dd99d334865becfb1ec3503c50a26dcdf2909120" },
  "next": "Reviewer judges this tip; Triage closes #1267 after the merge"
}

@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Checkpoint body trimmed to fit the handoff limit; the tip is unchanged at dd99d33 (body-only edit, no commit, no rebase).

@Birfy

Birfy commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at dd99d33
Written-by: Reviewer2
Attestation: dd99d33 role=Reviewer2

Ran at this tip (tarball of dd99d33, base 0472c16):

  • node scripts/ds-bot-patches.mjs check → skipped pin (no git) / ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/, exit 0
  • cmp of all 194 files under patches/ds-bot/files/ against their core/ copy → byte-identical, 0 differences
  • node --test test/rounds.test.js → # tests 55 / # pass 55 / # fail 0
  • node --test test/context-sharing.test.js test/room-membership.test.js → # tests 27 / # pass 27 / # fail 0
  • red knife: core/src/host/groups.js put back to base 0472c16, then node --test --test-force-exit --test-name-pattern=1267 test/rounds.test.js → three not ok, # tests 3 / # pass 0 / # fail 3, errors the line reaches the member that is speaking, not the next slot / the speaker has it / the speaking slot is superseded; restored → cmp clean, 55/55
  • node .github/scripts/changelog.mjs check → 13 fragment(s) in changelog.d/ are well formed, exit 0
  • tree diff base↔tip → exactly the 6 files of pulls/1284/files (core/src/host/groups.js +80/−3, core/test/rounds.test.js +94/−2, docs/subsystems/core-host.md +4, patches/ds-bot/0005-social-seams.patch +123/−6, patches/ds-bot/0012-room-membership.patch +119/−35, changelog.d/1267-mid-turn-line.fixed.md added); patches/ds-bot/files/ unchanged
  • section diff inside the two patches → 0012 changed only src/host/groups.js, 0005 only test/rounds.test.js; check's second line is the equality proof for both hunks that were spliced with the script's own normalizeDiff

Not run:

  • the whole core/ suite and npm run lint / the arch-docs gate — no oxlint and no git on this box; the Node 24 and package checks cover them at this tip
  • a live DSH where the owner types while a Bot is visibly working — no shell here; the test writes into the same Session queue waitingLines reads, and the delivery is the existing rt.deliver(steer: true) path

Checked, no actionable findings:

  • ROOM_STOP is exactly the seven words, anchored, trailing punctuation only; 停一下 / 停下吧 stay ordinary lines, as the body says
  • watchSpeakerLines polls waitingLines(agent) — the same agent.inbox.nextStep / nextTurn the round drains — every 100 ms, unref'd, cleared by runRound's finally; a stop line is left in the queue and ends the watch
  • a stop marks the slot superseded, pushes the stopped reply and calls participant.cancel({sessionId, exchangeId}), which reaches rt.stopRoomTurn and cancels that exchange only (a turn with another exchangeId keeps going); if (slot.status === 'superseded') continue keeps the stopped text out of the log and out of round.replies
  • round.midLineIds (deleted by drainRoomInbox) lets the head drain open the fresh stop line without logging or answering it a second time; notePostedLine plus the relay-copy removal keeps the next relay from repeating it
  • slotLabel renders superseded as (stopped by the user)
  • checkpoint: pr 1284, issue 1267 (Fixes #1267 in the body), files the same six, checks.at this tip, summary 588 characters — under 600 after the body-only trim at 11:15:16Z, which did not move the tip
  • scope: the host round and its test, the two patch hunks, the owned subsystem page (core/src/host/ → core-host) and one changelog fragment; no authz, storage, review path or queue touched

Non-blocking:

  • user-visible behaviour change: a line typed while a member speaks now reaches it mid-turn, and the seven stop words cancel the speaking turn — worth the owner knowing before it ships
  • the relay's pending copy is dropped by text match, so two identical lines inside one 100 ms poll leave one copy (documented in the body)
  • a steer lands at the member's next model step; a turn inside a long tool call still answers later
  • the red knife leaves the round awaiting a turn that never comes, so the file-level runner hangs after the three assertion failures; --test-force-exit is what made the three failures report cleanly

This is not merge authority.

@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 b4e1faa Oct 26, 2026
13 of 16 checks passed
@Birfy
Birfy deleted the fix/1267-mid-turn-line-to-speaker branch October 26, 2026 11:25
@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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A line sent into a group chat while a member is speaking must reach that member at its next step, not after its turn

1 participant