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.
- package/dist/agent-scheduler/index.js +6 -2
- package/dist/auth-broker/index.js +9 -2
- package/dist/cli/notion-write-pretool.mjs +6 -2
- package/dist/cli/switchroom.js +908 -527
- package/dist/host-control/main.js +10 -3
- package/dist/vault/approvals/kernel-server.js +10 -2
- package/dist/vault/broker/server.js +10 -2
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +6 -0
- package/profiles/_base/start.sh.hbs +40 -4
- package/telegram-plugin/dist/gateway/gateway.js +275 -109
- package/telegram-plugin/gateway/gateway.ts +53 -52
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
- package/telegram-plugin/status-no-truncate.ts +49 -0
- package/telegram-plugin/status-pin-driver.ts +28 -0
- package/telegram-plugin/status-pin.ts +33 -4
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
- package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
- package/telegram-plugin/tests/status-pin.test.ts +128 -2
- package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +61 -18
- package/telegram-plugin/uat/assertions.ts +21 -2
- package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +38 -17
- 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 {
|
|
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
|
|
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
|
|
9147
|
+
const runReconcileFrom = (from: PinState | null, want: DesiredPin) =>
|
|
9135
9148
|
reconcilePin({
|
|
9136
9149
|
api: statusPinApi(),
|
|
9137
9150
|
chatId,
|
|
9138
|
-
prevState:
|
|
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
|
-
//
|
|
9159
|
-
|
|
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
|
-
|
|
9194
|
-
|
|
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` (
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|