Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5abfa2c
docs(card): 设计实时卡片 Pin 生命周期
TWT233 Aug 28, 2026
a17db04
docs(card): 拆解实时卡片 Pin 实现计划
TWT233 Aug 28, 2026
dad37d6
feat(card): 添加实时卡片置顶配置入口
TWT233 Aug 28, 2026
28816f1
fix(card): 对齐实时卡片置顶配置契约
TWT233 Aug 28, 2026
0f5bcfc
feat(lark): 添加消息 Pin 传输封装
TWT233 Aug 28, 2026
ebd1a89
fix(lark): 加固消息 Pin 传输门禁
TWT233 Aug 28, 2026
6f8324f
feat(card): 接入实时卡片 Pin 生命周期
TWT233 Aug 28, 2026
e3b853e
fix(card): 加固实时卡片 Pin 并发边界
TWT233 Aug 28, 2026
ea65269
fix(card): 保留 Pin 竞态下的后继发卡
TWT233 Aug 28, 2026
03c196f
feat(card): 支持实时卡片 Pin 热重算
TWT233 Aug 28, 2026
cc3f0bf
fix(card): 围栏恢复卡片 Pin 竞态
TWT233 Aug 28, 2026
fb5bc92
fix(card): 兼容 Pin 热重算异步回调失败
TWT233 Aug 28, 2026
bb35bf2
fix(card): 围栏恢复卡片 PATCH 失败
TWT233 Aug 28, 2026
225d58b
fix(card): 串行化 Pin 热重算最新态
TWT233 Aug 28, 2026
0f0fcc0
fix(card): 简化 Pin 队列收尾判定
TWT233 Aug 28, 2026
b21caba
test(lark): 修正 Pin 用例文件尾格式
TWT233 Aug 28, 2026
917e265
fix(pin-streaming-card): 串行化置顶卡配置变更
TWT233 Aug 28, 2026
59725c9
chore(pin-streaming-card): 移除本地修复报告跟踪
TWT233 Aug 28, 2026
9971484
test(pin-streaming-card): 覆盖跨应用独立串行
TWT233 Aug 28, 2026
626e652
fix(worker-pool): 收紧流式卡片生命周期门禁
TWT233 Aug 28, 2026
0800b43
fix(worker-pool): 拆分流式卡片 Pin 链路
TWT233 Aug 28, 2026
d1ed506
fix(worker-pool): 收紧流式卡片 gate recovery Pin 队列
TWT233 Aug 28, 2026
4eb0d53
fix(card): 恢复卡片置顶不阻塞回执
TWT233 Aug 28, 2026
1c9f9e2
fix(card): 收紧实时卡片关闭转移清理
TWT233 Aug 28, 2026
64cbbb9
fix(card): 收紧恢复卡片提交围栏
TWT233 Aug 28, 2026
697943b
fix(card): 恢复卡片保留转移门禁
TWT233 Aug 28, 2026
95607ff
test(card): 补齐置顶清理生命周期覆盖
TWT233 Aug 28, 2026
eb84bfe
test(card): 明确转移置顶断言范围
TWT233 Aug 28, 2026
2b48574
merge(card): 集成实时卡片 Pin 生命周期加固
TWT233 Aug 28, 2026
2208fca
fix(worker-pool): 收窄关闭路径持久化 owner 边界
TWT233 Aug 28, 2026
6375948
fix(streaming-card): 收紧重启后置顶卡片关闭清理边界
TWT233 Aug 28, 2026
874d3aa
merge(streaming-card): 集成最终置顶生命周期修复
TWT233 Aug 28, 2026
acf6389
docs(card): 明确置顶来源丢失边界
TWT233 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .superpowers/sdd/2026-08-28-pin-streaming-card/task-3-5-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Tasks 3-5: 实时卡片 Pin 生命周期

## Status

Implemented as one lifecycle unit. The focused lifecycle matrix and build pass.

## Files

- `src/core/worker-pool.ts`
- `src/im/lark/card-handler.ts`
- `test/streaming-card-pinning.test.ts`

## TDD evidence

### Task 3 policy

- RED: `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts` failed with `pinStreamingCardIfEnabled is not a function` and `reconcileStreamingCardPins is not a function`.
- GREEN: `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts` passed: 63 tests.
- Coverage added: default-off and invalid/sentinel/inactive/displaced refusals, ownership/current-ID checking, stale post-Pin compensating Unpin, enable Pin-before-frozen-Unpin, disable deduplicated cleanup, and cross-topic frozen IDs.

### Tasks 4-5 integration

- RED diagnostic: the first expanded publication matrix exposed a real fence-scope regression (`ReferenceError: ownsFreshReadyPost is not defined`) and one dependent worker-ready assertion failed.
- GREEN after fix: publication/resume matrix passed: 142 tests.
- Close/transfer matrix passed: 140 tests.

## Verification

- Focused final matrix: 9 files, 279 tests passed.
- Build: `mise exec bun@1.4.0 -- bun run build` passed.
- `git diff --check` passed.
- Full unit suite: completed with 18,803 passed and 14 failed, plus 17 skipped. Failures are unrelated baseline/environment issues: `/home` versus `/data00/home` path-alias expectations in runtime/plugin/npm/OMP/Grok tests, Bubblewrap sandbox cleanup permissions and MCP connection closure, process-name expectation under Bun (`MainThread` vs `node`), and DSH sandbox timeout. None are in the Pin lifecycle matrix.

## Race invariants reviewed

- Only real `streamCardId` values are eligible; the posting sentinel is rejected.
- Pin checks active status, route ownership, object/current-ID identity, transfer state, and preference before and after the request. A stale successful Pin gets a best-effort compensating Unpin of the captured ID.
- Fresh `/card`, worker-ready, and screen-update POST paths recheck captured session/app/anchor/registry/nonce state before commit; stale posts are deleted without Pin.
- New cards persist before Pin; frozen IDs are unpinned only after successor Pin succeeds; destination-sensitive `recallFrozenCards` remains unchanged and always executes afterward.
- Persisted reuse reconciles the live ID after its PATCH. Resume repost captures identity before POST and deletes stale results without Pin.
- Transfer and close snapshot/deduplicate IDs before source fields or frozen sidecars disappear, then launch best-effort Unpin only after their durable commit. Neither path awaits Unpin; refused or failed durable close does no cleanup.

## Self-review and concerns

- Pin/Unpin failures are fail-open and do not change publication, resume, transfer, or close results.
- No repo picker, private card, final card, CoT, or closed-card path calls the policy; calls are restricted to `streamCardId` lifecycle points.
- The intentional scope has no durable Pin-operation journal or chat-wide Pin scan. A crash between remote mutation and later lifecycle reconciliation can leave a stale Pin until a later known lifecycle boundary; this matches the approved QoL/fail-open design.

## Fix round 1/5

### Findings resolved

- Publication continuations now capture/deduplicate frozen predecessor IDs before awaiting the successor Pin. `reconcilePublishedStreamingCard` returns whether the captured real card is still the authoritative current identity after Pin; turn-start, `/card`, worker-ready fresh POST, and screen-update POST use that result to skip `recallFrozenCards` and all later post-publication mutation if a successor won the race.
- Screen-update POST rejection now requires both the broad lifecycle fence and its captured POST nonce/sentinel fence before rollback, clear, or persistence. A rejected stale request therefore cannot erase a successor `streamCardId`.
- Resume repost rechecks its captured session/app/registry/current-card identity after its awaited Pin before deleting the stale predecessor or sending a receipt.
- Removed the obsolete post-Pin frozen-ID snapshot helper: predecessor IDs are now always captured before the await.
- Added actual-path coverage for deferred ready Pin ownership loss, deferred screen-update POST rejection, transfer source cleanup after routing commit, and close’s non-deleting asynchronous Unpin. Existing focused harnesses continue to cover turn-start, `/card`, worker-ready reuse/fresh, screen-update, resume, transfer, and close.

### TDD evidence

- RED: the deferred worker-ready Pin test failed because the older continuation called `recallFrozenCards`, deleting `om_frozen_predecessor` after a successor became current.
- GREEN: after fencing post-Pin continuation effects, that test passes and confirms the stale card gets only a compensating Unpin.
- Screen-update deferred rejection test confirms a stale POST error leaves `om_successor` unchanged.

### Verification

- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 282 tests passed.
- `mise exec bun@1.4.0 -- bun run build` — passed.
- `git diff --check` — passed (no output).

## Persisted worker-ready reuse PATCH rejection fence

### Finding resolved

- The persisted reuse restore-PATCH rejection path now uses the same captured restored-card ownership fence as its success path. If a successor wins while `updateMessage` is in flight, the old catch exits without clearing `streamCardId` or falling through to a fresh-card POST.
- If the restored card still owns the authoritative identity, the previous PATCH-failure fallback remains unchanged: clear the failed restored ID and continue to the normal fresh POST path.

### TDD evidence

- RED: deferred `updateMessage` rejection after successor takeover changed `om_successor` into fallback `om_new_card`.
- GREEN: the successor remains current and no fallback POST is sent after the stale rejection.

### Verification

- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 288 tests passed.
- `mise exec bun@1.4.0 -- bun run build` — passed.
- `git diff --check` — passed (no output).

## Persisted worker-ready reuse race fix

### Finding resolved

- The persisted worker-ready reuse branch now captures session, app, anchor, registry key, and `restoredCardId` before its restore PATCH/Pin reconciliation. After the awaited reconciliation it rechecks that the captured card is still the authoritative current identity before recalling frozen cards, publishing reuse completion, or arming the usage refresh.
- A lost-ownership old restore stays fail-open: Pin policy compensates the old Pin with Unpin, while the reuse continuation leaves successor-owned card and frozen state untouched.

### TDD evidence

- RED: a deferred Pin during the real persisted worker-ready reuse path removed `om_frozen_predecessor` after `streamCardId` changed to `om_successor`.
- GREEN: the same test preserves `om_successor` and its frozen entry, emits no predecessor deletion, and verifies compensating Unpin of `om_restored_card`.

### Verification

- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 287 tests passed.
- `mise exec bun@1.4.0 -- bun run build` — passed.
- `git diff --check` — passed (no output).

## Fix round 2/5

### Finding resolved

- A stale publication after an awaited Pin must suppress captured-card mutation, but it must not strand a later turn already marked pending. Turn-start, worker-ready fresh POST, and screen-update POST now retain their successor-card scheduling path after `reconcilePublishedStreamingCard()` reports lost ownership.
- The scheduling predicate rechecks the live `streamCardTurnGeneration` after the await instead of relying on the pre-await `superseded` snapshot. This preserves the successor's liveness while the captured-ID fence still excludes recall, refresh patches, timer arming, and other old-card side effects.

### TDD evidence

- RED: three deferred-Pin liveness tests failed with only one POST: turn-start, worker-ready, and screen-update each left the successor pending card unscheduled after the older card lost ownership.
- GREEN: each orchestration shape now posts its successor (two POSTs total) and clears the successor pending turn, while the earlier concurrency test continues to prove stale continuations do not recall the successor state.

### Verification

- `mise exec bun@1.4.0 -- bun run test -- test/streaming-card-pinning.test.ts test/recall-frozen-cards.test.ts test/worker-ready-display-mode.test.ts test/card-integration.test.ts test/card-handler-resume-receipt.test.ts test/transfer-session.test.ts test/session-delete-close-barrier.test.ts test/mojo-explicit-close.test.ts test/close-stream-card-untouched.test.ts` — 9 files passed, 285 tests passed.
- `mise exec bun@1.4.0 -- bun run build` — passed.
- `git diff --check` — passed (no output).
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Tasks 6-7: 实时卡片 Pin 热重算与文档

## Status

Implemented as one hot-reconciliation and documentation unit. The focused Task 6/7 matrix passed after fixing the active-session key collision in the multi-session reconciliation coverage.

## Files

- `src/services/pin-streaming-card-change.ts`
- `src/services/bot-config-store.ts`
- `src/services/card-prefs-store.ts`
- `src/daemon.ts`
- `test/pin-streaming-card-change.test.ts`
- `test/bot-config-store.test.ts`
- `test/card-prefs-auto-start.test.ts`
- `test/command-handler.test.ts`
- `test/dashboard-ipc.test.ts`
- `test/streaming-card-pinning.test.ts`
- `docs-site/docs/zh/bots-json.md`
- `docs-site/docs/en/bots-json.md`
- `docs-site/docs/zh/cards.md`
- `docs-site/docs/en/cards.md`

## TDD evidence

### Task 6 hot reconciliation seam

- RED: `mise exec bun@1.4.0 -- bun run test -- test/pin-streaming-card-change.test.ts` failed because `src/services/pin-streaming-card-change.ts` did not exist.
- GREEN: the new process-local callback seam passed coverage for registration, disposal, replacement, and swallowed/logged handler failures.
- Store-path RED diagnostics: the first expanded config-path matrix showed missing notifications and incorrect blocking assumptions in the `/botconfig` and dashboard flows.
- GREEN after wiring: `bot-config-store` now notifies only after successful `pinStreamingCard` write plus live-memory sync; `card-prefs-store` now notifies only when `patch.pinStreamingCard !== undefined` after successful write plus live-memory sync; unrelated patches and failed writes emit nothing.
- Non-blocking confirmation: `/botconfig` and dashboard mutation tests prove the visible response completes even when the registered reconciliation handler throws or does deferred work.

### Task 6 active-session reconciliation coverage

- Daemon startup now registers `registerPinStreamingCardChangeHandler(reconcileBotStreamingCardPins)` immediately after `setActiveSessionsRegistry(activeSessions);`, preserving the required `reconcileBotStreamingCardPins(larkAppId, enabled): void` interface and keeping the seam in the services layer to avoid a worker-pool import cycle.
- The multi-session reconciliation test initially failed twice for real harness reasons: first because sessions reused the same `sessionId`, then because `activeSessionKey(ds)` is derived from `rootMessageId` under thread scope and the test still reused the same root anchor.
- GREEN after fix: the helper now supports distinct `sessionId`, `rootMessageId`, and explicit `scope: 'thread'`, and the coverage proves reconciliation snapshots only the target bot's active sessions, ignores other bots, and isolates one session failure from the rest.

### Task 7 documentation

- Added bilingual documentation for `pinStreamingCard` in bot config and cards docs.
- The docs now state the exact approved scope: per-bot opt-in, default off, only the current public live-status `streamCardId` participates, hot on/off reconciliation applies to existing active sessions, repo picker/private `/card`/final reply/CoT/closed/other cards remain unpinned, failures are fail-open, temporary zero or multiple Pins are possible, and there is no durable retry journal or full-chat Pin audit after exceptional crashes.

## Verification

- Focused matrix: `mise exec bun@1.4.0 -- bun run test -- test/pin-streaming-card-change.test.ts test/bot-config-store.test.ts test/card-prefs-auto-start.test.ts test/command-handler.test.ts test/dashboard-ipc.test.ts test/streaming-card-pinning.test.ts` passed.
- Full suite: `mise exec bun@1.4.0 -- bun run test` still reports unrelated baseline failures, not introduced by this unit. Observed failing files remain `test/codex-browser-broker.test.ts`, `test/grok-transcript.test.ts`, `test/npm-binary-distribution.test.ts`, `test/cli-runtime-update.test.ts`, `test/session-discovery.smoke.test.ts`, `test/plugin-mcp-sandbox.test.ts`, and `test/oh-my-pi-legacy-migration.test.ts`.
- Build: `mise exec bun@1.4.0 -- bun run build` passed.
- `git diff --check` passed.

## Self-review and concerns

- Notification is intentionally fail-open. Reconciliation handler failures are swallowed and logged so config writes and API responses are never blocked by Feishu Pin/Unpin work.
- The callback seam is process-local and has no durable backlog. This matches the approved scope and means an exceptional crash between remote state mutation and the next lifecycle boundary can leave a stale Pin until a later reconciliation opportunity.
- The implementation preserves the narrow contract: only `streamCardId` participates, `pinStreamingCard` stays default-off, and no broader card classes were added to the policy.

## Follow-up fix: async handler rejection remains fail-open

- Review found that TypeScript still allows an `async` `PinStreamingCardChangeHandler` even when the type was declared as `void`, so the original `notifyPinStreamingCardChanged()` only caught synchronous throws and could leak an unhandled Promise rejection.
- The seam contract now explicitly accepts `void | PromiseLike<void>`. `notifyPinStreamingCardChanged()` remains non-blocking, wraps the return value with `Promise.resolve(...)`, and logs any asynchronous rejection via `.catch(...)` while still catching synchronous throws in the outer `try/catch`.
- Added a regression test proving `notifyPinStreamingCardChanged()` does not throw or block when the handler rejects asynchronously, and that the rejection is consumed and logged.

## Follow-up fix: bot-wide latest-state reconciliation queue

- Review found a bot-wide race on rapid `pinStreamingCard` toggles: an older disable reconciliation could still finish its deferred Unpin after a newer enable started, leaving the final `on` state without the current card pinned.
- `reconcileBotStreamingCardPins()` now uses a per-`larkAppId` fire-and-forget queue. Different bots still reconcile in parallel, but each bot serializes generations and collapses them to the latest desired state before rerunning.
- Each generation takes a fresh snapshot of matching active sessions only: same `larkAppId`, `session.status === 'active'`, current registry owner for that session key, and not displaced by a newer live owner. Disable uses that same bounded snapshot.
- Inside one bot generation, per-session reconciliation now runs concurrently with `Promise.allSettled(...)`, so one slow or failing session does not block peer sessions while bot-level generations remain ordered.
- The queue teardown now tracks the last absorbed desired version explicitly. Because no `await` occurs between the loop's `break` decision and `finally`, a fresh toggle cannot interleave in that window; deleting the queue only when the settled version still matches the live state avoids dropping a just-arrived notification while keeping the cleanup branch simple.
- Added lifecycle regressions for deferred disable→enable and enable→disable ordering, plus snapshot filtering for inactive and displaced sessions, and a test-only queue reset to prevent cross-test leakage.
1 change: 1 addition & 0 deletions docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ This option addresses one narrow gap: Codex running through Botmux's app-server
| `brandLabel` | Branding text at the bottom of the card. `undefined` = default `botmux` link; `""` = hidden; any other string = rendered as-is (supports markdown). Purely cosmetic, does not affect routing / permissions |
| `showUsageInCardFooter` | Whether reply-card footers show native Context / Token usage from the Agent CLI. Missing / `true` = show; `false` = hide both metrics. A missing individual metric is still omitted independently. This controls card display only and does not disable the Usage Ledger or other accounting |
| `disableStreamingCard` | When `true`, no real-time streaming session card is sent at all (the Web Terminal still runs and the final reply still arrives via `botmux send`, there's just no auto-refreshing status card). For users who find the real-time card noisy |
| `pinStreamingCard` | When `true`, the bot **pins the current public live-status card**. It is opt-in and default-off: only an explicit `true` enables it. Only the current public live-status real `streamCardId` participates; repo-picker cards, private `/card` snapshots, final reply cards, CoT, closed cards, and every other interactive card stay out of scope. The switch is hot-updated: once dashboard or `/botconfig set pinStreamingCard on/off` successfully writes local config and changes the effective value, Botmux runs a best-effort reconciliation across this bot's **existing active sessions**. The configuration response does **not wait** for Feishu Pin/Unpin calls. Failures never interrupt publication, transfer, resume, close, or configuration itself; during exceptional periods there may temporarily be zero or multiple Pins. This feature adds **no durable retry journal and no full-chat Pin audit**: an explicit on-to-off transition cleans the session's known live-card IDs; if the setting is already off and process-local provenance was lost, later close or transfer does not risk removing a Pin that may have been created manually |
| `silentTurnReactions` | When `true`, card-off sessions no longer add GoGoGo / DONE reactions to the triggering message. Only affects the lightweight status reactions used when `disableStreamingCard` or `noCardChats` suppresses live cards; defaults to `false` |
| `receivedReactionEmoji` | Feishu emoji_type for the "received" reaction in card-off sessions; `undefined` = default `GoGoGo` (冲!). Free-form string; a bad value just silently fails to attach (best-effort) |
| `doneReactionEmoji` | Feishu emoji_type for the "done" reaction in card-off sessions; `undefined` = default `DONE` (✅). Set it equal to `receivedReactionEmoji` to keep the marker unchanged on turn-end — handy for CLIs whose idle detection can fire early (e.g. Pi), avoiding a premature, misleading ✅ |
Expand Down
Loading