switchroom 0.19.25 → 0.19.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +9 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +908 -527
  5. package/dist/host-control/main.js +10 -3
  6. package/dist/vault/approvals/kernel-server.js +10 -2
  7. package/dist/vault/broker/server.js +10 -2
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +6 -0
  10. package/profiles/_base/start.sh.hbs +40 -4
  11. package/telegram-plugin/dist/gateway/gateway.js +275 -109
  12. package/telegram-plugin/gateway/gateway.ts +53 -52
  13. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  14. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  15. package/telegram-plugin/status-no-truncate.ts +49 -0
  16. package/telegram-plugin/status-pin-driver.ts +28 -0
  17. package/telegram-plugin/status-pin.ts +33 -4
  18. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  19. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  20. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  21. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  22. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  23. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  24. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  25. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  26. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  27. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  28. package/telegram-plugin/tier-downgrade.ts +3 -2
  29. package/telegram-plugin/tool-activity-summary.ts +61 -18
  30. package/telegram-plugin/uat/assertions.ts +21 -2
  31. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  32. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  33. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  34. package/telegram-plugin/worker-activity-feed.ts +38 -17
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
@@ -197,7 +197,7 @@ import {
197
197
  import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
198
198
  import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
199
199
  import type { PinState, DesiredPin } from '../status-pin.js'
200
- import { decidePinAction, PinRightsCache } from '../status-pin.js'
200
+ import { PinRightsCache } from '../status-pin.js'
201
201
  import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
202
202
  import { parseSourceMessageId } from './source-message-id.js'
203
203
  import {
@@ -637,6 +637,9 @@ import type { HostdRequest, HostdResponse } from '../../src/host-control/protoco
637
637
  import type { AgentAudit } from '../welcome-text.js'
638
638
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
639
639
  import { createBootSweepGate, runBootPinSweepSteps } from './boot-sweep-gate.js'
640
+ import { runStatusPinReconcile } from './status-pin-retarget.js'
641
+ import { createPeriodicSweepGuard } from './periodic-sweep-guard.js'
642
+ import { withDeadline } from './with-deadline.js'
640
643
  import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
641
644
  import {
642
645
  createDmPinSweeper,
@@ -666,11 +669,9 @@ import { buildCapturedDeliverySnapshot, createCapturedResumeDispatcher } from '.
666
669
  import {
667
670
  loadStatusPins,
668
671
  mutateStatusPinRow,
669
- reconcileAndPersistStatusPin,
670
672
  runStatusPinBootCleanup,
671
673
  withPinReconcileLock,
672
674
  type PersistedStatusPin,
673
- type StatusPinPersistOp,
674
675
  } from './status-pin-store.js'
675
676
  import {
676
677
  loadActivityCards,
@@ -9069,8 +9070,20 @@ async function runMidSessionCardReaper(): Promise<void> {
9069
9070
  }
9070
9071
  }
9071
9072
 
9073
+ // Single-flight: a pass awaits Telegram calls that can park behind a flood-wait
9074
+ // for longer than the interval, so ticks fanned out. See periodic-sweep-guard.ts.
9075
+ const midSessionReaperGuard = createPeriodicSweepGuard({
9076
+ run: runMidSessionCardReaper,
9077
+ onSkip: () => process.stderr.write(
9078
+ 'telegram gateway: mid-session reaper tick skipped — previous pass still running\n',
9079
+ ),
9080
+ onError: (err) => process.stderr.write(
9081
+ `telegram gateway: mid-session reaper pass threw: ${(err as Error).message}\n`,
9082
+ ),
9083
+ })
9084
+
9072
9085
  const midSessionCardReaper = isGatewayMain ? setInterval(() => { // #2996 P0c gate
9073
- void runMidSessionCardReaper()
9086
+ void midSessionReaperGuard.tick()
9074
9087
  }, MID_SESSION_CARD_REAPER_INTERVAL_MS) : undefined
9075
9088
  midSessionCardReaper?.unref()
9076
9089
 
@@ -9131,12 +9144,12 @@ async function reconcileStatusPinInner(
9131
9144
  // older duplicate-pin concern (two edits both reading prev=null).
9132
9145
  const prev = statusPinState.get(pinKey) ?? null
9133
9146
 
9134
- const runReconcile = () =>
9147
+ const runReconcileFrom = (from: PinState | null, want: DesiredPin) =>
9135
9148
  reconcilePin({
9136
9149
  api: statusPinApi(),
9137
9150
  chatId,
9138
- prevState: prev,
9139
- desired,
9151
+ prevState: from,
9152
+ desired: want,
9140
9153
  rightsCache: statusPinRightsCache,
9141
9154
  onPinRightsDisabled: (chat) => {
9142
9155
  // Logged ONCE per chat per process (#3024). Every subsequent auto-pin
@@ -9155,53 +9168,23 @@ async function reconcileStatusPinInner(
9155
9168
  },
9156
9169
  })
9157
9170
 
9158
- // Classify the action so we persist INTENT before the pin API call. Only a
9159
- // fresh `pin` of a message that isn't already our claim opens the leak window
9160
- // (the API call actually pins something new); everything else (unpin, noop,
9161
- // re-pin of the same id) clears / leaves the record and is safe to persist
9162
- // after. See reconcileAndPersistStatusPin for the ordering rationale.
9163
- const action = decidePinAction(prev, desired)
9164
- const op: StatusPinPersistOp =
9165
- action.kind === 'pin'
9166
- ? { kind: 'pin', messageId: action.messageId }
9167
- : { kind: 'clear' }
9168
-
9169
- if (!statusPinPersistEnabled) {
9170
- // Persistence off (STATIC / feature-off): just reconcile + update Maps.
9171
- const next = await runReconcile()
9172
- if (next == null) {
9173
- statusPinState.delete(pinKey)
9174
- statusPinChatIds.delete(pinKey)
9175
- statusPinPinnedAt.delete(pinKey)
9176
- } else {
9177
- statusPinState.set(pinKey, next)
9178
- statusPinChatIds.set(pinKey, chatId)
9179
- if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
9180
- }
9181
- return
9182
- }
9183
-
9184
- // Persist-BEFORE-pin ordering lives in reconcileAndPersistStatusPin: for a
9185
- // pin it writes a `pending` record first, then confirms it after the API call
9186
- // lands (or drops it on failure). A crash in the window leaves a pending
9187
- // record boot cleanup will unpin. In-memory Maps are updated from the result.
9188
- const next = await reconcileAndPersistStatusPin({
9189
- path: STATUS_PIN_STORE_PATH,
9190
- fs: statusPinStoreFs,
9171
+ // Persist ordering + the RETARGET two-leg expansion: status-pin-retarget.ts.
9172
+ await runStatusPinReconcile({
9191
9173
  pinKey,
9192
9174
  chatId,
9193
- op,
9194
- applyPin: runReconcile,
9175
+ prev,
9176
+ desired,
9177
+ // persist:null ⇒ no durable row (STATIC / feature-off).
9178
+ persist: statusPinPersistEnabled
9179
+ ? { path: STATUS_PIN_STORE_PATH, fs: statusPinStoreFs }
9180
+ : null,
9181
+ runPin: runReconcileFrom,
9182
+ registries: {
9183
+ state: statusPinState,
9184
+ chatIds: statusPinChatIds,
9185
+ pinnedAt: statusPinPinnedAt,
9186
+ },
9195
9187
  })
9196
- if (next == null) {
9197
- statusPinState.delete(pinKey)
9198
- statusPinChatIds.delete(pinKey)
9199
- statusPinPinnedAt.delete(pinKey)
9200
- } else {
9201
- statusPinState.set(pinKey, next)
9202
- statusPinChatIds.set(pinKey, chatId)
9203
- if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
9204
- }
9205
9188
  }
9206
9189
 
9207
9190
  // #3207: the per-worker `reconcileWorkerPin(agentId, …)` (keyed `wk:<agentId>`)
@@ -18053,7 +18036,7 @@ function broadcastTierNotice(markdown: string): void {
18053
18036
  * the user must re-issue `/model <premium>`, which the notice says plainly.
18054
18037
  * - Effort is NATIVE: no `.session-effort` carrier is written, so the restart
18055
18038
  * sheds any live /effort override and the downgraded default boots at the
18056
- * configured `thinking_effort` (the fleet `low` pin, #1978).
18039
+ * configured `thinking_effort` (#1978 / thinking-effort-risk.ts).
18057
18040
  * - Loop-bounded by the NATURAL on-default guard: after the downgrade boot the
18058
18041
  * session runs the configured default (override gone), so a re-entry returns
18059
18042
  * `skip` ('on-default') and never re-downgrades — even if the default is
@@ -22750,6 +22733,24 @@ async function shutdown(signal: string): Promise<void> {
22750
22733
  agentName,
22751
22734
  })
22752
22735
 
22736
+ // Status-pin: unpin everything we own before we exit. `sweepBeforeSelfRestart`
22737
+ // covered the gateway's OWN restart verbs, but SIGTERM/SIGINT — `docker
22738
+ // restart`, a compose bounce, a host reboot: the common path — reached
22739
+ // `process.exit(0)` with every live pin still pinned, leaving it at the top of
22740
+ // the chat until the NEXT boot's cleanup won the mutex and built a bot
22741
+ // (empirically: overlord logged `cleared 1/1 orphaned pin(s) from a prior
22742
+ // session` on a routine 2026-07-27 restart). Runs after the drain (no live
22743
+ // turn to race) and before `bot.api` goes away — `bot.stop()` only stops
22744
+ // polling. Deadline-bounded on purpose: a flood-wait-parked unpin must not
22745
+ // push shutdown into the `forceExitTimer` path, which skips the lock release.
22746
+ try {
22747
+ await withDeadline(unpinAllStatusPins(), 5_000, 'status-pin shutdown sweep timed out')
22748
+ } catch (err) {
22749
+ process.stderr.write(
22750
+ `telegram gateway: shutdown status-pin sweep incomplete: ${(err as Error).message}\n`,
22751
+ )
22752
+ }
22753
+
22753
22754
  // Now finish the cleanup the drain didn't touch.
22754
22755
  inboundCoalescer.reset()
22755
22756
  pendingReauthFlows.clear()
@@ -0,0 +1,86 @@
1
+ /**
2
+ * periodic-sweep-guard.ts — single-flight wrapper for an interval-driven sweep.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * `runMidSessionCardReaper` (gateway.ts) is dispatched from a `setInterval` as
7
+ * `void run()`, every 5 minutes by default. Its body awaits REAL Telegram calls:
8
+ * `robustApiCall` finalize edits for orphaned activity cards, and
9
+ * `reconcileStatusPin` unpins for stale `wk:` pins. Both retry with backoff and
10
+ * both can park behind a flood-wait — this gateway took a 62-minute flood ban on
11
+ * 2026-07-25.
12
+ *
13
+ * With no guard, every tick started ANOTHER concurrent pass. During a stall that
14
+ * is unbounded fan-out: N passes reading the same `status-pins.json`, deciding
15
+ * the same reaps, and firing duplicate unpins into the very rate limit that was
16
+ * slowing them down. Concurrent passes also race each other in the store-orphan
17
+ * branch, which unpins and drops the row OUTSIDE the per-key reconcile lock.
18
+ *
19
+ * The guard is deliberately SKIP, not queue: a tick arriving while a pass is
20
+ * running is dropped. The reaper is a backstop with no deadline, and the next
21
+ * tick is at most one interval away — queuing would just rebuild the same
22
+ * pile-up with extra latency.
23
+ *
24
+ * It also absorbs a throw out of the pass. The gateway's `unhandledRejection`
25
+ * handler crashes the process, and a fire-and-forget cosmetic sweep must never
26
+ * be able to do that (the same rule `reconcileStatusPin` follows after a benign
27
+ * pin-rights 400 took the whole gateway down, marko 2026-07-01).
28
+ */
29
+
30
+ export interface PeriodicSweepGuard {
31
+ /** Run the sweep unless a pass is already in flight. Never rejects. */
32
+ tick(): Promise<void>
33
+ /** True while a pass is in flight. Test/introspection seam. */
34
+ isRunning(): boolean
35
+ /** Passes skipped because one was already running. Test/introspection seam. */
36
+ skipped(): number
37
+ }
38
+
39
+ /**
40
+ * Wrap `run` so at most one pass is in flight at a time.
41
+ *
42
+ * `onSkip` / `onError` are optional observers (the gateway logs to stderr);
43
+ * both are themselves absorbed, so a broken logger cannot take down the one
44
+ * function whose job is to make the sweep un-crashable.
45
+ */
46
+ export function createPeriodicSweepGuard(args: {
47
+ run: () => Promise<void>
48
+ onSkip?: () => void
49
+ onError?: (err: unknown) => void
50
+ }): PeriodicSweepGuard {
51
+ let running = false
52
+ let skippedCount = 0
53
+
54
+ const notify = (fn: (() => void) | undefined): void => {
55
+ if (fn == null) return
56
+ try {
57
+ fn()
58
+ } catch {
59
+ /* an observer must never break the guard */
60
+ }
61
+ }
62
+
63
+ return {
64
+ async tick(): Promise<void> {
65
+ if (running) {
66
+ skippedCount += 1
67
+ notify(args.onSkip)
68
+ return
69
+ }
70
+ running = true
71
+ try {
72
+ await args.run()
73
+ } catch (err) {
74
+ notify(args.onError == null ? undefined : () => args.onError?.(err))
75
+ } finally {
76
+ running = false
77
+ }
78
+ },
79
+ isRunning(): boolean {
80
+ return running
81
+ },
82
+ skipped(): number {
83
+ return skippedCount
84
+ },
85
+ }
86
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * status-pin-retarget.ts — orchestrates a status-pin RETARGET as two persisted
3
+ * legs, so a single-shot caller still ends up with the new surface pinned.
4
+ *
5
+ * The defect this closes
6
+ * ----------------------
7
+ * `decidePinAction(prev, { pinned: true, messageId: NEW })` with a claim on a
8
+ * DIFFERENT message used to report a bare `unpin`, documented as "a subsequent
9
+ * desired-pin re-pins the new one on the next reconcile". That is true only for
10
+ * callers that RE-DRIVE the reconcile:
11
+ *
12
+ * - the worker-activity feed does (`syncPin` runs on every steady-state edit,
13
+ * `worker-activity-feed.ts`), so its group-message rotation converges;
14
+ * - the FOREGROUND activity card does NOT. `narrative-lane.ts` reconciles
15
+ * `fg:<statusKey>` exactly once, inside the `activityMessageId == null`
16
+ * OPEN branch; the `else` (edit) branch never touches the pin, and turn-end
17
+ * only ever unpins (`turn-end.ts`).
18
+ *
19
+ * So when the ack-first reopen path (`feed-reopen-gate.ts`) nulls
20
+ * `activityMessageId` mid-turn and a FRESH card opens, that one reconcile
21
+ * unpinned the old card and stopped. The live card ran unpinned for the rest of
22
+ * the turn, and — because the retarget mapped to a `clear` persist op — the
23
+ * durable `status-pins.json` row was deleted too, so neither the mid-session
24
+ * reaper nor the next boot's sweep had anything left to reconcile from.
25
+ *
26
+ * Why TWO legs and not one
27
+ * ------------------------
28
+ * `reconcileAndPersistStatusPin` carries the persist-BEFORE-pin contract for a
29
+ * single op. Running the retarget as one `pin`-op leg would write a pending row
30
+ * naming the NEW id before the OLD one is unpinned — a crash in that window
31
+ * erases the only durable record of a message that is still pinned. Splitting
32
+ * it keeps each leg's crash-safety exactly as designed:
33
+ *
34
+ * leg 1 unpin the stale claim, with the row still naming the OLD id;
35
+ * leg 2 pin the new message from a null claim, with its own pending row.
36
+ *
37
+ * Failure semantics are inherited from `reconcilePin`, not re-invented here: a
38
+ * leg-1 result that is NON-NULL means the unpin was never confirmed (#3664
39
+ * Defect B — the old message is provably still pinned and the claim was
40
+ * deliberately retained). Pinning the new message then would leave two pins in
41
+ * the chat with a durable record of one, so leg 2 is skipped and the retained
42
+ * claim is left for the next reconcile / the mid-session reaper / the boot
43
+ * sweep to retry.
44
+ *
45
+ * Dependency-free apart from the pure decision, so it is provable in isolation
46
+ * (`telegram-plugin/tests/status-pin-retarget.test.ts`); the gateway owns the
47
+ * wiring of `runLeg` / `commit`.
48
+ */
49
+
50
+ import type { DesiredPin, PinState } from '../status-pin.js'
51
+ import { decidePinAction } from '../status-pin.js'
52
+ import type { StatusPinPersistOp, StatusPinStoreFsSeam } from './status-pin-store.js'
53
+ import { reconcileAndPersistStatusPin } from './status-pin-store.js'
54
+
55
+ /** The three parallel in-memory maps the gateway keys by pinKey. */
56
+ export interface StatusPinRegistries {
57
+ /** The claim: which message we believe is pinned for this key. */
58
+ state: Map<string, PinState>
59
+ /** Which chat that claim lives in (the `wk:` reaper needs it to unpin). */
60
+ chatIds: Map<string, string>
61
+ /** When the claim was FIRST taken — the reaper's TTL age. */
62
+ pinnedAt: Map<string, number>
63
+ }
64
+
65
+ export interface StatusPinReconcileArgs {
66
+ pinKey: string
67
+ chatId: string
68
+ /** The claim held for this key when the reconcile started. */
69
+ prev: PinState | null
70
+ /** What the caller wants pinned for this key now. */
71
+ desired: DesiredPin
72
+ /** Durable-store binding, or null when persistence is off (STATIC). */
73
+ persist: { path: string; fs: StatusPinStoreFsSeam } | null
74
+ /**
75
+ * Execute the real Telegram pin/unpin for one leg (the gateway binds
76
+ * `reconcilePin`). Must never throw — API errors are absorbed inside.
77
+ */
78
+ runPin: (from: PinState | null, want: DesiredPin) => Promise<PinState | null>
79
+ registries: StatusPinRegistries
80
+ }
81
+
82
+ /**
83
+ * Drive `prev → desired` for one pin key, expanding a RETARGET into its two
84
+ * legs. Every other action (pin / unpin / noop) is a single leg, unchanged.
85
+ *
86
+ * Each leg is committed to the registries as it lands, so the in-memory claim
87
+ * never lags the durable row — including the intermediate cleared state between
88
+ * a retarget's two legs, which is what lets a concurrent reader (the `wk:`
89
+ * reaper) see an honest snapshot rather than a claim on an unpinned id.
90
+ *
91
+ * Callers must hold the per-key reconcile lock (`withPinReconcileLock`); `prev`
92
+ * is read once by the caller and both legs run inside that critical section.
93
+ */
94
+ export async function runStatusPinReconcile(args: StatusPinReconcileArgs): Promise<void> {
95
+ const { pinKey, chatId, prev, desired, persist, runPin, registries } = args
96
+
97
+ // Publish a leg's outcome. `pinnedAt` is set only when absent so it keeps the
98
+ // FIRST-taken timestamp; clearing the claim drops it, so a re-pin ages fresh.
99
+ const commit = (next: PinState | null): void => {
100
+ if (next == null) {
101
+ registries.state.delete(pinKey)
102
+ registries.chatIds.delete(pinKey)
103
+ registries.pinnedAt.delete(pinKey)
104
+ return
105
+ }
106
+ registries.state.set(pinKey, next)
107
+ registries.chatIds.set(pinKey, chatId)
108
+ if (!registries.pinnedAt.has(pinKey)) registries.pinnedAt.set(pinKey, Date.now())
109
+ }
110
+
111
+ // One leg, with the persist ordering its action requires. Only a fresh `pin`
112
+ // opens the crash window that persist-BEFORE-pin closes; unpin / noop / re-pin
113
+ // of the same id clear or leave the row and are safe to persist after.
114
+ const runLeg = (from: PinState | null, want: DesiredPin): Promise<PinState | null> => {
115
+ if (persist == null) return runPin(from, want)
116
+ const legAction = decidePinAction(from, want)
117
+ const op: StatusPinPersistOp =
118
+ legAction.kind === 'pin'
119
+ ? { kind: 'pin', messageId: legAction.messageId }
120
+ : { kind: 'clear' }
121
+ return reconcileAndPersistStatusPin({
122
+ path: persist.path,
123
+ fs: persist.fs,
124
+ pinKey,
125
+ chatId,
126
+ op,
127
+ applyPin: () => runPin(from, want),
128
+ })
129
+ }
130
+
131
+ const action = decidePinAction(prev, desired)
132
+
133
+ if (action.kind === 'repin' && desired.pinned) {
134
+ const afterUnpin = await runLeg(prev, { pinned: false })
135
+ commit(afterUnpin)
136
+ // Never-confirmed unpin (#3664 Defect B): keep the retained claim, skip the
137
+ // pin leg. See the docblock.
138
+ if (afterUnpin != null) return
139
+ commit(await runLeg(null, desired))
140
+ return
141
+ }
142
+
143
+ commit(await runLeg(prev, desired))
144
+ }
@@ -124,3 +124,52 @@ export const NESTED_PREFIX = ' ↳ '
124
124
  * assertion is what let #3662 ship green and inert.
125
125
  */
126
126
  export const WORKER_STEP_INDENT = '\u2800\u2800\u2800'
127
+
128
+ /**
129
+ * Line-1 prefix that marks a card as structurally SUBORDINATE to the \ud83e\udd16 agent
130
+ * card (#3820). Worker cards carry it; the agent card never does.
131
+ *
132
+ * `\u2514\u2500` (U+2514 BOX DRAWINGS LIGHT UP AND RIGHT + U+2500 BOX DRAWINGS LIGHT
133
+ * HORIZONTAL) is ordinary ink to Telegram's server-side GFM parser: it is not
134
+ * a line-start block trigger (`#`, `>`, `-`/`+`/`*`, `N.` \u2014 see
135
+ * render/line-start-guard.ts for the full trigger set), so it can neither be
136
+ * promoted to a list/quote/heading nor left-trimmed the way a leading
137
+ * whitespace run is (the #3662 failure documented on WORKER_STEP_INDENT).
138
+ *
139
+ * LENGTH INVARIANT: exactly the same length as `SUBORDINATE_LINE_INDENT`, so
140
+ * the char-budget arithmetic in `fitCardToBudget` charges one flat per-line
141
+ * cost instead of special-casing line 1. `status-accent.test.ts` asserts it.
142
+ */
143
+ export const SUBORDINATE_HEADER_PREFIX = '\u2514\u2500 '
144
+
145
+ /**
146
+ * Left indent applied to EVERY line of a subordinate (worker) card after
147
+ * line 1 \u2014 the whole card block sits one level in from the agent card's left
148
+ * margin, so "parent vs child" is readable from the block's SHAPE at a glance
149
+ * on a phone, not from reading its label (#3820).
150
+ *
151
+ * Same U+2800 run as `WORKER_STEP_INDENT` and for the same live-verified
152
+ * reason (category So, not Zs \u2192 survives Telegram's inline left-trim; ASCII
153
+ * spaces and U+00A0 both render flat). Aliased rather than re-declared so the
154
+ * two indents can never drift to different glyphs.
155
+ *
156
+ * On the combined (2+ worker) card this composes with `WORKER_STEP_INDENT`:
157
+ * chrome / row headers land at one level, their steps at two.
158
+ */
159
+ export const SUBORDINATE_LINE_INDENT = WORKER_STEP_INDENT
160
+
161
+ /**
162
+ * Apply subordinate-card nesting to a card's pre-rendered lines: line 1 gets
163
+ * `SUBORDINATE_HEADER_PREFIX`, every later line gets `SUBORDINATE_LINE_INDENT`.
164
+ *
165
+ * Prefixes go OUTSIDE the markdown spans (lines arrive already wrapped in
166
+ * `**` / `_` / `~~`), exactly like `renderStepFeed`'s `indent` parameter, so a
167
+ * prefix can never land inside an emphasis run and break it.
168
+ *
169
+ * Pure; returns a new array. Empty in \u2192 empty out.
170
+ */
171
+ export function nestSubordinateCardLines(lines: string[]): string[] {
172
+ return lines.map((line, i) =>
173
+ i === 0 ? `${SUBORDINATE_HEADER_PREFIX}${line}` : `${SUBORDINATE_LINE_INDENT}${line}`,
174
+ )
175
+ }
@@ -86,6 +86,12 @@ export interface ReconcilePinArgs {
86
86
  * success and on a TERMINAL failure (never leave state stuck
87
87
  * pinned). A never-confirmed failure returns prevState so the
88
88
  * still-pinned message keeps a record to retry from (#3664).
89
+ * - `repin`: the wanted message CHANGED — unpins the stale claim then pins
90
+ * the new message, both in this one call, returning the new
91
+ * claim. A never-confirmed unpin aborts the pin leg and retains
92
+ * the old claim (the old message is still up and must keep a
93
+ * record). Single-shot callers depend on this: they never
94
+ * reconcile the key a second time.
89
95
  * - `noop` : returns prevState unchanged.
90
96
  */
91
97
  export async function reconcilePin(
@@ -95,6 +101,28 @@ export async function reconcilePin(
95
101
 
96
102
  if (action.kind === 'noop') return args.prevState
97
103
 
104
+ if (action.kind === 'repin') {
105
+ // RETARGET: unpin the stale claim, then pin the new message — BOTH legs in
106
+ // this one call. Callers that reconcile a key exactly once (the foreground
107
+ // activity card) have no "next reconcile" to finish the job, so splitting
108
+ // it left nothing pinned. See the `repin` docblock in status-pin.ts.
109
+ const afterUnpin = await reconcilePin({
110
+ ...args,
111
+ desired: { pinned: false },
112
+ })
113
+ // A non-null result here means the unpin was NEVER CONFIRMED (#3664
114
+ // Defect B): the old message is provably still pinned and the claim was
115
+ // deliberately retained. Pinning the new one now would leave two pins with
116
+ // a record of only one. Keep the retained claim and let the next reconcile
117
+ // / the mid-session reaper / the boot sweep retry.
118
+ if (afterUnpin != null) return afterUnpin
119
+ return reconcilePin({
120
+ ...args,
121
+ prevState: null,
122
+ desired: { pinned: true, messageId: action.pinMessageId },
123
+ })
124
+ }
125
+
98
126
  if (action.kind === 'unpin') {
99
127
  // Skip the unpin API call in a chat the bot can't manage pins in — the
100
128
  // call would fail with the same rights 400 and spam the log. The claim is
@@ -48,6 +48,29 @@ export type PinAction =
48
48
  * or a TERMINAL failure; a never-confirmed failure keeps the claim so the
49
49
  * still-pinned message can be retried (`isUnpinTerminalError`, #3664). */
50
50
  | { kind: 'unpin'; messageId: number }
51
+ /**
52
+ * RETARGET — the caller still wants something pinned for this key, but a
53
+ * DIFFERENT message than the one we claim (the surface was re-posted: an
54
+ * ack-first activity-card reopen, a worker-feed group-message rotation).
55
+ *
56
+ * This is ONE action, not two reconciles. It used to be reported as a plain
57
+ * `unpin`, on the assumption that "a subsequent desired-pin re-pins the new
58
+ * one on the next reconcile". That assumption holds only for callers that
59
+ * RE-DRIVE the reconcile (the worker feed's `syncPin`, called on every
60
+ * steady-state edit). The foreground activity card does not: it reconciles
61
+ * exactly once, when a card OPENs (`narrative-lane.ts` — the `else` branch
62
+ * only EDITs and never touches the pin). So a mid-turn card reopen (the
63
+ * ack-first path in `feed-reopen-gate.ts`, which nulls `activityMessageId`
64
+ * so a fresh card opens) unpinned the old card and left the NEW one
65
+ * unpinned for the rest of the turn — the pin vanished exactly when the
66
+ * turn still needed it.
67
+ *
68
+ * Executors MUST complete both legs: unpin `unpinMessageId`, then pin
69
+ * `pinMessageId`. `reconcilePin` owns the failure semantics (a
70
+ * never-confirmed unpin retains the OLD claim and skips the pin, so the last
71
+ * record of a provably-still-pinned message is never lost).
72
+ */
73
+ | { kind: 'repin'; unpinMessageId: number; pinMessageId: number }
51
74
 
52
75
  /**
53
76
  * Decide the single pin action for a key given the previously-claimed
@@ -71,10 +94,16 @@ export function decidePinAction(
71
94
  if (prev.messageId === desired.messageId) {
72
95
  return { kind: 'noop', reason: 'already pinned this message' }
73
96
  }
74
- // The message we want pinned changed (the feed re-posted after a stale
75
- // edit dropped its id). Unpin the old claim; a subsequent desired-pin
76
- // re-pins the new one on the next reconcile.
77
- return { kind: 'unpin', messageId: prev.messageId }
97
+ // The message we want pinned changed (the surface was re-posted). Report a
98
+ // RETARGET, not a bare unpin: the executor must drop the stale claim AND pin
99
+ // the new message in the same reconcile. Relying on "the next reconcile" to
100
+ // pin left every single-shot caller (the foreground activity card) with
101
+ // nothing pinned — see the `repin` docblock above.
102
+ return {
103
+ kind: 'repin',
104
+ unpinMessageId: prev.messageId,
105
+ pinMessageId: desired.messageId,
106
+ }
78
107
  }
79
108
 
80
109
  /** Extract a lowercased human description from any thrown value, preferring