switchroom 0.20.4 → 0.20.6

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.20.4", COMMIT_SHA = "7f669382";
2123
+ var VERSION = "0.20.6", COMMIT_SHA = "4b1ad9b1";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -32408,7 +32408,9 @@ var init_scaffold = __esm(() => {
32408
32408
  "mcp__switchroom-telegram__forward_message",
32409
32409
  "mcp__switchroom-telegram__download_attachment",
32410
32410
  "mcp__switchroom-telegram__get_recent_messages",
32411
- "mcp__switchroom-telegram__progress_update"
32411
+ "mcp__switchroom-telegram__progress_update",
32412
+ "mcp__switchroom-telegram__send_checklist",
32413
+ "mcp__switchroom-telegram__update_checklist"
32412
32414
  ];
32413
32415
  HINDSIGHT_MCP_TOOLS = [
32414
32416
  "mcp__hindsight__recall",
@@ -32464,6 +32466,7 @@ var init_scaffold = __esm(() => {
32464
32466
  "Task",
32465
32467
  "TodoWrite",
32466
32468
  "ExitPlanMode",
32469
+ "Bash",
32467
32470
  "Skill"
32468
32471
  ];
32469
32472
  WEBKITE_FLEET_DENY_TOOLS = ["WebFetch", "WebSearch"];
@@ -21432,7 +21432,7 @@ function allocateAgentUid(name) {
21432
21432
  }
21433
21433
 
21434
21434
  // src/build-info.ts
21435
- var VERSION = "0.20.4";
21435
+ var VERSION = "0.20.6";
21436
21436
 
21437
21437
  // src/setup/hindsight-recall-tunables.ts
21438
21438
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.20.4",
4
+ "version": "0.20.6",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -15,6 +15,7 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
15
15
  - Cutting the tag fires **two** workflows: `docker-images` (builds + pushes the 6 ghcr images) and `release` (the **orchestrator** — builds the four static binaries, attaches them, waits for `docker-images`, then calls `npm-publish`, then takes the GitHub Release out of draft).
16
16
  - **`npm-publish.yml` no longer has a tag trigger (#3654).** It is reachable only via `workflow_call` from `release.yml` and via `workflow_dispatch`. npm is the one leg that cannot be undone, so it runs last and only once everything else is green. It also re-proves both preconditions from inside its own run, so a hand dispatch cannot bypass the ordering either.
17
17
  - **The GitHub Release is created as a DRAFT and stays one until every leg is green.** `install.sh` resolves the version to install from `/releases/latest`, and that endpoint excludes drafts — so a half-finished release is invisible to `curl | sh` users and the previous complete release keeps serving them. This is not theoretical: v0.19.19 shipped published-with-zero-assets and broke the installer on every platform.
18
+ - **A tag push is now SUFFICIENT: if no GitHub Release exists, `release.yml`'s `guard` job AUTO-CREATES it as a draft from the `## vX.Y.Z` section of `CHANGELOG.md` (#4331).** The manual `gh release create` in step 2b is therefore belt-and-suspenders — do it (it lets you pin an exact title/notes and shortens the guard's wait), but forgetting it no longer silently strands the release. This closed the v0.20.3/v0.20.4 hole: those tags shipped to the fleet with `gh release create` skipped, so `guard` failed, `npm` was skipped, and npm `latest` sat at 0.20.2 until it was recovered by hand. The one hard requirement is that `CHANGELOG.md` on the tagged commit HAS a section for the tag — if it doesn't, `guard` FAILS LOUDLY (it never invents an empty release).
18
19
 
19
20
  ## Before you start — pre-flight (verify, don't assume)
20
21
 
@@ -55,7 +56,7 @@ gh release create "vX.Y.Z" -R switchroom/switchroom \
55
56
  ```
56
57
 
57
58
  - **`--draft` is mandatory.** A published release with no assets immediately becomes `/releases/latest` and 404s every `curl | sh` install for the entire ~25-minute build window. `release.yml` will forcibly re-draft an incomplete published release within about a minute, but do not rely on the safety net — it exists for the case where this step was done wrong.
58
- - Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job needs it to exist within ~150s of the tag push.
59
+ - Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job polls ~150s for it. **If you skip this step entirely, `guard` auto-creates the draft release from `CHANGELOG.md` (#4331)** — so a skipped `gh release create` is recoverable, not fatal, as long as the CHANGELOG section exists. Still create it here when you can: it is one fewer thing depending on the auto-heal, and it lets you author a title/notes that differ from the raw CHANGELOG section.
59
60
  - **Notes extraction gotcha (historical):** the naive `awk '/^## vX/,/^## v/' CHANGELOG` range collapses to a single line. Use a start-flag awk: `awk 'f{print} /^## vX\.Y\.Z/{print; f=1} f && /^## v/ && !/^## vX\.Y\.Z/{exit}'` — or extract the section to a temp file by line range.
60
61
  - **`gh release create` has been silently dropped in past runs.** Verify it exists and is a draft:
61
62
  ```bash
@@ -151,7 +152,7 @@ It re-runs every leg — including `finalize`, which is what actually takes the
151
152
 
152
153
  **But a re-dispatch cannot fix a defect that lives in the tag's own tree.** `workflow_dispatch` executes the workflow YAML **at the dispatched ref**, so `--ref vX.Y.Z` re-runs the same broken file and fails identically, every time. This is not theoretical — it is how v0.19.20 died (#3691). Before reaching for the re-dispatch, root-cause the failure in the workflow source **at that tag** (`git show vX.Y.Z:.github/workflows/release.yml`) and compare it to `main`. If the bug is in the tag's tree, the only path is a fix merged to `main` plus a **fresh tag**; abandon the old tag as a permanent draft (do not delete it, do not hand-publish it) and burn a patch version. Re-dispatch is for *transient* failures — a flaky runner, an npm 5xx, a `docker-images` run that has since gone green.
153
154
 
154
- - **`release` failed at `guard` ("no GitHub Release exists"):** step 2b was skipped or the tag name is misspelled. Create the draft release, then re-dispatch as above.
155
+ - **`release` failed at `guard` ("cannot auto-create … no usable CHANGELOG.md section"):** the tag has no matching `## vX.Y.Z` section in `CHANGELOG.md` on the tagged commit, so `guard` could not auto-create the release from it (#4331). This is the fail-loud fallback, not the old "no release exists" failure — `guard` now auto-creates the DRAFT release from the CHANGELOG section whenever `gh release create` was skipped, so a missing release object no longer blocks the pipeline. The fix is a CHANGELOG defect in the tag's own tree: consolidate the notes under `## vX.Y.Z — <summary>` (step 1), merge to `main`, and cut a **fresh** tag — a re-dispatch on the same tag re-runs the same CHANGELOG-less tree and fails identically (see the "cannot fix a defect in the tag's tree" note above). If instead you simply want to override the auto-notes, `gh release create vX.Y.Z --draft --notes-file …` by hand before re-dispatching still works.
155
156
  - **`release` failed at `images-gate`:** `docker-images` was not green for this tag+commit. Fix it, `gh workflow run docker-images.yml --ref vX.Y.Z`, wait for green, then re-dispatch `release.yml`. Nothing was published to npm and the release is still a draft — nothing to undo.
156
157
  - **`release` failed at `npm` (e.g. a transient npm 5xx):** re-dispatch `release.yml` as above. `npm-publish` treats "already published" as success, so a re-run is safe and idempotent. Dispatching `npm-publish.yml --ref vX.Y.Z` directly also works and its own gates still apply, but it will NOT un-draft the release, so you would then have to re-dispatch `release.yml` anyway.
157
158
  - **Everything is green but the release is still a draft:** `finalize` did not run. Check `gh run view <run-id>` for a skipped job, then re-dispatch. Do not hand-publish — `finalize` re-verifies the asset set immediately before flipping the flag.
@@ -71555,6 +71555,7 @@ __export(exports_silence_poke, {
71555
71555
  noteThinking: () => noteThinking,
71556
71556
  noteProduction: () => noteProduction,
71557
71557
  noteOutbound: () => noteOutbound2,
71558
+ noteCardRender: () => noteCardRender,
71558
71559
  noteBackgroundShellDead: () => noteBackgroundShellDead,
71559
71560
  noteBackgroundShellAlive: () => noteBackgroundShellAlive,
71560
71561
  longestInFlightTool: () => longestInFlightTool,
@@ -71567,7 +71568,8 @@ __export(exports_silence_poke, {
71567
71568
  __getStateForTests: () => __getStateForTests,
71568
71569
  __bgMarkerParserConfirmedForTests: () => __bgMarkerParserConfirmedForTests,
71569
71570
  DEFAULT_THRESHOLDS: () => DEFAULT_THRESHOLDS,
71570
- DEFAULT_POLL_INTERVAL_MS: () => DEFAULT_POLL_INTERVAL_MS
71571
+ DEFAULT_POLL_INTERVAL_MS: () => DEFAULT_POLL_INTERVAL_MS,
71572
+ CARD_RENDER_FRESH_MS: () => CARD_RENDER_FRESH_MS
71571
71573
  });
71572
71574
 
71573
71575
  // turn-liveness-floor.ts
@@ -71633,6 +71635,7 @@ var DEFAULT_THRESHOLDS = {
71633
71635
  fallback: 300000
71634
71636
  };
71635
71637
  var DEFAULT_POLL_INTERVAL_MS = 5000;
71638
+ var CARD_RENDER_FRESH_MS = 30000;
71636
71639
  var state2 = new Map;
71637
71640
  var timer = null;
71638
71641
  var activeDeps = null;
@@ -71651,6 +71654,7 @@ function startTurn(key, now) {
71651
71654
  fallbackFired: false,
71652
71655
  floorFired: false,
71653
71656
  inFlightTools: new Map,
71657
+ lastCardRenderAt: null,
71654
71658
  sawBashThisTurn: false,
71655
71659
  aliveShells: new Set
71656
71660
  });
@@ -71682,6 +71686,12 @@ function noteProduction(key, now) {
71682
71686
  s.lastOutboundAt = now;
71683
71687
  s.fallbackFired = false;
71684
71688
  }
71689
+ function noteCardRender(key, now) {
71690
+ const s = state2.get(key);
71691
+ if (s == null)
71692
+ return;
71693
+ s.lastCardRenderAt = now;
71694
+ }
71685
71695
  function noteThinking(key, now) {
71686
71696
  const s = state2.get(key);
71687
71697
  if (s == null)
@@ -71828,6 +71838,8 @@ function tick(now) {
71828
71838
  if (underCeiling) {
71829
71839
  if (activeDeps.isCompactionInFlight?.(key) === true)
71830
71840
  continue;
71841
+ if (s.lastCardRenderAt != null && now - s.lastCardRenderAt < CARD_RENDER_FRESH_MS)
71842
+ continue;
71831
71843
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === "0";
71832
71844
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
71833
71845
  if (activeDeps.isLegitimatelyWorking(key))
@@ -82138,6 +82150,12 @@ function noteProduction2(key, now) {
82138
82150
  s.lastOutboundAt = now;
82139
82151
  s.fallbackFired = false;
82140
82152
  }
82153
+ function noteCardRender2(key, now) {
82154
+ const s = state5.get(key);
82155
+ if (s == null)
82156
+ return;
82157
+ s.lastCardRenderAt = now;
82158
+ }
82141
82159
  function endTurn2(key) {
82142
82160
  state5.delete(key);
82143
82161
  }
@@ -83706,6 +83724,7 @@ function createNarrativeLane(deps) {
83706
83724
  if (isSendGateShed(editRes))
83707
83725
  break;
83708
83726
  }
83727
+ noteCardRender2(statusKey(chat, thread), Date.now());
83709
83728
  turn.activityLastSentRender = target;
83710
83729
  } catch (err) {
83711
83730
  const msg = err instanceof Error ? err.message : String(err);
@@ -89472,6 +89491,7 @@ async function runBootPinSweepSteps(deps) {
89472
89491
  await step("status-pin-cleanup", deps.statusPinCleanup, log);
89473
89492
  await step("activity-card-reaper", deps.activityCardReaper, log);
89474
89493
  await step("queued-card-reaper", deps.queuedCardReaper, log);
89494
+ await step("seed-prune-sweep-cursors", async () => deps.seedPruneSweepCursors(), log);
89475
89495
  await step("enable-sweep", async () => deps.enableSweep(), log);
89476
89496
  for (const t of targets) {
89477
89497
  await step(`stale-pin-sweep:${sweepTargetKey(t.chatId, t.threadId)}`, () => deps.sweepTarget(t), log);
@@ -90284,6 +90304,56 @@ function createGatewayStalePinSweeper(w) {
90284
90304
  });
90285
90305
  }
90286
90306
 
90307
+ // gateway/stale-pin-sweep-store.ts
90308
+ var SWEEP_ENVELOPE_VERSION2 = 1;
90309
+ function isCursorRow2(x) {
90310
+ if (x == null || typeof x !== "object")
90311
+ return false;
90312
+ const o = x;
90313
+ return typeof o.chatId === "string" && o.chatId.length > 0 && (o.threadId === undefined || typeof o.threadId === "number") && (o.kind === "dm" || o.kind === "forum-topic" || o.kind === "supergroup") && typeof o.popped === "number" && typeof o.done === "boolean" && typeof o.attempts === "number" && (o.doneIds === undefined || Array.isArray(o.doneIds) && o.doneIds.every((n) => typeof n === "number")) && (o.lastStatus === undefined || typeof o.lastStatus === "string") && typeof o.updatedAt === "number";
90314
+ }
90315
+ function loadSweepCursors2(path2, fs2) {
90316
+ if (!fs2.existsSync(path2))
90317
+ return [];
90318
+ let raw = "";
90319
+ try {
90320
+ raw = fs2.readFileSync(path2);
90321
+ } catch {
90322
+ return [];
90323
+ }
90324
+ let parsed;
90325
+ try {
90326
+ parsed = JSON.parse(raw);
90327
+ } catch {
90328
+ return [];
90329
+ }
90330
+ if (parsed == null || typeof parsed !== "object")
90331
+ return [];
90332
+ const env = parsed;
90333
+ if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.cursors)) {
90334
+ return [];
90335
+ }
90336
+ return env.cursors.filter(isCursorRow2);
90337
+ }
90338
+ function persistSweepCursors2(path2, fs2, cursors, log = (l) => process.stderr.write(l)) {
90339
+ const env = { v: SWEEP_ENVELOPE_VERSION2, cursors: [...cursors] };
90340
+ try {
90341
+ fs2.writeFileSync(path2, JSON.stringify(env));
90342
+ } catch (err) {
90343
+ log(`stale-pin-sweep-store: persist FAILED path=${path2}: ${err.message} \u2014 ` + `durability degraded; a restart mid-sweep will re-seed instead of resume
90344
+ `);
90345
+ }
90346
+ }
90347
+ function pruneSweepCursors(cursors) {
90348
+ return cursors.filter((c) => !c.done);
90349
+ }
90350
+ function reseedSweepLedger(path2, fs2, log = (l) => process.stderr.write(l)) {
90351
+ const rows = loadSweepCursors2(path2, fs2);
90352
+ const pruned = pruneSweepCursors(rows);
90353
+ if (pruned.length !== rows.length)
90354
+ persistSweepCursors2(path2, fs2, pruned, log);
90355
+ }
90356
+
90287
90357
  // gateway/webhook-ingest-server.ts
90288
90358
  import net4 from "node:net";
90289
90359
  import { chmodSync as chmodSync10, existsSync as existsSync39, unlinkSync as unlinkSync18 } from "node:fs";
@@ -102409,10 +102479,10 @@ function startOutboxSweep(deps) {
102409
102479
  }
102410
102480
 
102411
102481
  // ../src/build-info.ts
102412
- var VERSION2 = "0.20.4";
102413
- var COMMIT_SHA = "7f669382";
102414
- var COMMIT_DATE = "2026-08-04T01:43:38Z";
102415
- var LATEST_PR = 4331;
102482
+ var VERSION2 = "0.20.6";
102483
+ var COMMIT_SHA = "4b1ad9b1";
102484
+ var COMMIT_DATE = "2026-08-04T07:34:19Z";
102485
+ var LATEST_PR = 4340;
102416
102486
  var COMMITS_AHEAD_OF_TAG = 0;
102417
102487
 
102418
102488
  // gateway/boot-version.ts
@@ -108121,19 +108191,17 @@ async function unpinAllStatusPins() {
108121
108191
  }
108122
108192
  var stalePinSweepEligible = false;
108123
108193
  var STALE_PIN_SWEEP_STORE_PATH = join71(STATE_DIR, "stale-pin-sweep.json");
108194
+ var sweepStoreFs = {
108195
+ readFileSync: (p) => readFileSync64(p, "utf-8"),
108196
+ writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
108197
+ existsSync: (p) => existsSync61(p)
108198
+ };
108124
108199
  var stalePinSweeper = createGatewayStalePinSweeper({
108125
108200
  telegram: { handle: () => lockedBot, call: robustApiCall },
108126
108201
  claims: () => statusPinClaims.values(),
108127
108202
  loadPinRows: () => statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled ? loadStatusPins2(STATUS_PIN_STORE_PATH, statusPinStoreFs) : [],
108128
108203
  eligible: () => stalePinSweepEligible,
108129
- store: {
108130
- path: STALE_PIN_SWEEP_STORE_PATH,
108131
- fs: {
108132
- readFileSync: (p) => readFileSync64(p, "utf-8"),
108133
- writeFileSync: (p, data) => atomicWriteFileSync(p, data, 384),
108134
- existsSync: (p) => existsSync61(p)
108135
- }
108136
- },
108204
+ store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
108137
108205
  allowUnpinAllForumTopic: process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC == null ? undefined : process.env.SWITCHROOM_PIN_SWEEP_UNPIN_ALL_TOPIC === "1"
108138
108206
  });
108139
108207
  function runBootPinCleanupAndStalePinSweep() {
@@ -108149,6 +108217,7 @@ function runBootPinCleanupAndStalePinSweep() {
108149
108217
  enableSweep: () => {
108150
108218
  stalePinSweepEligible = true;
108151
108219
  },
108220
+ seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
108152
108221
  sweepTarget: (t) => stalePinSweeper.sweepTarget(t),
108153
108222
  log: (line) => process.stderr.write(line)
108154
108223
  });
@@ -109926,6 +109995,9 @@ async function executeProgressUpdate(args) {
109926
109995
  try {
109927
109996
  noteSignal(key, Date.now());
109928
109997
  } catch {}
109998
+ try {
109999
+ noteOutbound2(key, Date.now());
110000
+ } catch {}
109929
110001
  return {
109930
110002
  content: [
109931
110003
  {
@@ -103,6 +103,21 @@ export interface BootPinSweepSteps {
103
103
  /** Flips the flag authorising the stale-pin drain — for this sweep AND for
104
104
  * later lazy first-inbound sweeps. */
105
105
  enableSweep: () => void
106
+ /**
107
+ * BOOT-SEED PRUNE (#3953 regression fix). Drops discharged (`done: true`)
108
+ * cursor rows from the sweep ledger so a `(chat, thread)` that drained in a
109
+ * PRIOR session is re-evaluated live this boot, instead of short-circuiting
110
+ * forever on a stale `done`. Kind-agnostic — clears DM, forum-topic and
111
+ * supergroup rows alike. Forfeited rows (`attempts >= SWEEP_MAX_ATTEMPTS`)
112
+ * are RETAINED by the underlying `pruneSweepCursors`, so a chat the bot
113
+ * cannot pin does not re-burn its attempt budget every boot.
114
+ *
115
+ * MUST stay BOOT-scoped. The lazy first-inbound sweep fires on EVERY message;
116
+ * pruning there would re-arm a full re-drain per message = a Telegram flood.
117
+ * Runs once here, before the `sweepTarget` loop, so the loop re-evaluates the
118
+ * freshly-reseeded ledger.
119
+ */
120
+ seedPruneSweepCursors: () => void
106
121
  sweepTarget: (target: SweepTarget) => Promise<unknown>
107
122
  log?: (line: string) => void
108
123
  }
@@ -161,6 +176,15 @@ export async function runBootPinSweepSteps(deps: BootPinSweepSteps): Promise<voi
161
176
  await step('activity-card-reaper', deps.activityCardReaper, log)
162
177
  await step('queued-card-reaper', deps.queuedCardReaper, log)
163
178
 
179
+ // Boot-seed prune (#3953): reseed the cursor ledger so a chat that drained in
180
+ // a prior session is re-evaluated live below, rather than short-circuiting on
181
+ // a stale `done`. Boot-scoped ONLY — never on the per-inbound path. Runs
182
+ // BEFORE enableSweep so eligibility (which also gates the lazy first-inbound
183
+ // sweep) is granted only once the ledger is reseeded — no window where an
184
+ // inbound observes a stale `done`. Isolated: a throw here must not strand the
185
+ // drain, and enableSweep below still runs regardless.
186
+ await step('seed-prune-sweep-cursors', async () => deps.seedPruneSweepCursors(), log)
187
+
164
188
  // Unconditional: reached even when every step above threw. See the docblock.
165
189
  await step('enable-sweep', async () => deps.enableSweep(), log)
166
190
  for (const t of targets) {
@@ -648,6 +648,7 @@ import { withDeadline } from './with-deadline.js'
648
648
  import { createStatusPinApi, type PinCapableBot, type RobustApiSeam } from './status-pin-api.js'
649
649
  import { collectSweepTargets, type StalePinSweeper, type SweepTarget } from './stale-pin-sweep.js'
650
650
  import { createGatewayStalePinSweeper } from './stale-pin-sweep-wiring.js'
651
+ import { reseedSweepLedger } from './stale-pin-sweep-store.js'
651
652
  import { atomicWriteFileSync } from '../../src/util/atomic.js'
652
653
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
653
654
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
@@ -9041,6 +9042,11 @@ let stalePinSweepEligible = false
9041
9042
  // the previous complete ledger or the new one — never a truncated file that
9042
9043
  // forgets an in-flight drain.
9043
9044
  const STALE_PIN_SWEEP_STORE_PATH = join(STATE_DIR, 'stale-pin-sweep.json')
9045
+ const sweepStoreFs = {
9046
+ readFileSync: (p: string) => readFileSync(p, 'utf-8'),
9047
+ writeFileSync: (p: string, data: string) => atomicWriteFileSync(p, data, 0o600),
9048
+ existsSync: (p: string) => existsSync(p),
9049
+ }
9044
9050
  const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9045
9051
  telegram: { handle: () => lockedBot, call: robustApiCall },
9046
9052
  claims: () => statusPinClaims.values(),
@@ -9049,14 +9055,7 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
9049
9055
  ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
9050
9056
  : [],
9051
9057
  eligible: () => stalePinSweepEligible,
9052
- store: {
9053
- path: STALE_PIN_SWEEP_STORE_PATH,
9054
- fs: {
9055
- readFileSync: (p) => readFileSync(p, 'utf-8'),
9056
- writeFileSync: (p, data) => atomicWriteFileSync(p, data, 0o600),
9057
- existsSync: (p) => existsSync(p),
9058
- },
9059
- },
9058
+ store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
9060
9059
  // Per-deployment override only. UNSET (the normal case) means "take the
9061
9060
  // standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
9062
9061
  // i.e. the WHOLESALE topic drain stays off because it also removes pins this
@@ -9099,6 +9098,7 @@ function runBootPinCleanupAndStalePinSweep(): Promise<void> {
9099
9098
  enableSweep: () => {
9100
9099
  stalePinSweepEligible = true
9101
9100
  },
9101
+ seedPruneSweepCursors: () => reseedSweepLedger(STALE_PIN_SWEEP_STORE_PATH, sweepStoreFs),
9102
9102
  sweepTarget: (t: SweepTarget) => stalePinSweeper.sweepTarget(t),
9103
9103
  log: (line) => process.stderr.write(line),
9104
9104
  })
@@ -12573,6 +12573,8 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
12573
12573
  try {
12574
12574
  signalTracker.noteSignal(key, Date.now())
12575
12575
  } catch { /* best-effort signal */ }
12576
+ // #4330: model-driven user-visible outbound — reset the silence clock like a reply send.
12577
+ try { silencePoke.noteOutbound(key, Date.now()) } catch { /* best-effort */ }
12576
12578
 
12577
12579
  return {
12578
12580
  content: [
@@ -47,6 +47,7 @@
47
47
  // ─────────────────────────────────────────────────────────────────────────
48
48
 
49
49
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
50
+ import * as silencePoke from '../silence-poke.js'
50
51
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
51
52
  import { richMessage } from '../rich-send.js'
52
53
  import { isSendGateShed } from '../send-gate.js'
@@ -496,6 +497,20 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
496
497
  // payload IS on screen, so it advances normally below.
497
498
  if (isSendGateShed(editRes)) break
498
499
  }
500
+ // #4330 — a card render LANDED (fresh open above, or a non-shed
501
+ // edit): the pinned "→ Working…" status card the user watches just
502
+ // visibly moved. Stamp silence-poke's card-render clock so the 300s
503
+ // terminal fallback is DEFERRED (bounded by the hard ceiling) while
504
+ // the card keeps updating — a visibly-live card must not be torn
505
+ // down as "silence". This is the single transport chokepoint for
506
+ // every card producer (tool label, narrative SHOW, liveness open,
507
+ // heartbeat climb), and it is a DEFER-stamp only — it never touches
508
+ // `lastOutboundAt`, so the silence CLOCK still resets only at the
509
+ // model-driven production sites (stream-render tool-label / draft,
510
+ // foreground sub-agent render). That keeps the #1556 heartbeat-
511
+ // safety guard intact: a hung turn's climbing card defers the
512
+ // teardown at most to `fallbackHardCeiling`, never forever.
513
+ silencePoke.noteCardRender(statusKey(chat, thread), Date.now())
499
514
  turn.activityLastSentRender = target
500
515
  } catch (err) {
501
516
  const msg = err instanceof Error ? err.message : String(err)
@@ -221,7 +221,44 @@ export function pendingSweepCursors(cursors: readonly SweepCursor[]): SweepCurso
221
221
  return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
222
222
  }
223
223
 
224
- /** Drop discharged and forfeited rows — called once the ledger is reseeded. */
224
+ /**
225
+ * Boot seed pass: drop DISCHARGED (`done: true`) rows ONLY, so a chat that
226
+ * drained in a prior session is re-evaluated LIVE on the next sweep instead of
227
+ * short-circuiting forever on a stale `done` (#3953 regression — the seed pass
228
+ * was never wired, so `done` was effectively permanent).
229
+ *
230
+ * FORFEITED rows (`attempts >= SWEEP_MAX_ATTEMPTS`, still `!done`) are
231
+ * deliberately RETAINED. Their whole purpose is to remember that a chat's
232
+ * attempt budget is spent (bot kicked, pin rights revoked, a chat that
233
+ * flood-waits forever). Pruning one would let the next boot re-seed it from the
234
+ * pin stores and re-burn the full 8-attempt budget on every boot for the rest
235
+ * of time — precisely the Telegram flood the attempt cap exists to prevent. So
236
+ * this filters on `done` ALONE; it must NOT also gate on `attempts`.
237
+ */
225
238
  export function pruneSweepCursors(cursors: readonly SweepCursor[]): SweepCursor[] {
226
- return cursors.filter((c) => !c.done && c.attempts < SWEEP_MAX_ATTEMPTS)
239
+ return cursors.filter((c) => !c.done)
240
+ }
241
+
242
+ /**
243
+ * BOOT-SEED reseed of the durable ledger (#3953): drop discharged (`done:true`)
244
+ * rows so a `(chat, thread)` that drained in a prior session is re-evaluated
245
+ * LIVE on the next sweep instead of short-circuiting forever on a stale `done`.
246
+ * Forfeited rows are retained (see {@link pruneSweepCursors}).
247
+ *
248
+ * Writes ONLY when the prune changed the ledger, so a no-op boot never churns
249
+ * the durable file. Never throws — a reseed failure degrades to the pre-fix
250
+ * behaviour (the stale `done` survives one more boot), it must not break boot.
251
+ *
252
+ * BOOT-SCOPED BY CONTRACT: the caller must invoke this once at boot, never on
253
+ * the per-inbound path (which fires on every message — re-arming a full re-drain
254
+ * per message is a Telegram flood).
255
+ */
256
+ export function reseedSweepLedger(
257
+ path: string,
258
+ fs: SweepStoreFsSeam,
259
+ log: (line: string) => void = (l) => process.stderr.write(l),
260
+ ): void {
261
+ const rows = loadSweepCursors(path, fs)
262
+ const pruned = pruneSweepCursors(rows)
263
+ if (pruned.length !== rows.length) persistSweepCursors(path, fs, pruned, log)
227
264
  }
@@ -32,6 +32,8 @@ import {
32
32
  import {
33
33
  SWEEP_MAX_ATTEMPTS,
34
34
  loadSweepCursors,
35
+ pruneSweepCursors,
36
+ reseedSweepLedger,
35
37
  upsertSweepCursor,
36
38
  type SweepCursor,
37
39
  type SweepStoreFsSeam,
@@ -907,3 +909,163 @@ describe('stale-pin sweep — classification and seeding', () => {
907
909
  expect(isNothingToUnpinError(new Error('something else'))).toBe(false)
908
910
  })
909
911
  })
912
+
913
+ // ─── #3953: the boot-seed prune reseeds a discharged obligation ───────────────
914
+ //
915
+ // Regression #3953 replaced the per-boot DM self-heal with a cursor-gated stack
916
+ // drain, but the boot "seed pass" that clears discharged cursors was never
917
+ // wired — so `done:true` was effectively PERMANENT and every later boot /
918
+ // first-inbound sweep short-circuited on `already-drained`, orphaning any pin
919
+ // that leaked AFTER the first drain. These tests pin the reseed contract:
920
+ // 1. `pruneSweepCursors` drops `done` rows ONLY and RETAINS forfeited
921
+ // (attempts-exhausted) rows — a store-level test that FAILS on the pre-fix
922
+ // filter (which also dropped forfeited rows, re-burning the attempt budget
923
+ // every boot = flood risk).
924
+ // 2. Running that prune between two fresh-process sweeps re-arms the drain, so
925
+ // a pin leaked after a prior discharge is reaped — for a DM AND a
926
+ // supergroup (channel-class) target alike.
927
+
928
+ /** The boot-seed reseed exactly as the gateway wires it (`gateway.ts`
929
+ * `seedPruneSweepCursors` → `reseedSweepLedger`). */
930
+ function bootSeedPrune(fs: SweepStoreFsSeam, path: string): void {
931
+ reseedSweepLedger(path, fs, () => {})
932
+ }
933
+
934
+ /** A sweeper over a SHARED durable store — a distinct instance models a fresh
935
+ * process (reboot), so the only state carried across is the on-disk ledger. */
936
+ function sweeperOver(
937
+ fs: SweepStoreFsSeam,
938
+ path: string,
939
+ fake: ReturnType<typeof fakeChat>,
940
+ opts: { recordedPinIds?: number[] } = {},
941
+ ) {
942
+ let clock = 1_000_000
943
+ const deps: StalePinSweepDeps = {
944
+ getTopPinnedMessageId: fake.getTopPinnedMessageId,
945
+ pinSilent: fake.pinSilent,
946
+ unpin: fake.unpin,
947
+ unpinAllForumTopicMessages: fake.unpinAllForumTopicMessages,
948
+ canPinInChat: fake.canPinInChat,
949
+ protectedMessageIds: () => [],
950
+ recordedPinIds: () => opts.recordedPinIds ?? [],
951
+ eligible: () => true,
952
+ sleep: async (ms) => {
953
+ clock += ms
954
+ },
955
+ now: () => clock,
956
+ store: { path, fs },
957
+ log: () => {},
958
+ }
959
+ return createStalePinSweeper(deps)
960
+ }
961
+
962
+ describe('pruneSweepCursors — boot-seed reseed (#3953)', () => {
963
+ const now = 1_000_000
964
+
965
+ it('drops discharged rows but RETAINS forfeited (attempts-exhausted) rows', () => {
966
+ const discharged: SweepCursor = {
967
+ chatId: DM,
968
+ kind: 'dm',
969
+ popped: 3,
970
+ done: true,
971
+ attempts: 1,
972
+ updatedAt: now,
973
+ }
974
+ // A no-rights group that spent its whole attempt budget: `done` is false,
975
+ // but re-seeding it would re-burn all 8 attempts on the NEXT boot, and every
976
+ // boot after — the exact Telegram flood the attempt cap exists to stop.
977
+ const forfeited: SweepCursor = {
978
+ chatId: GROUP,
979
+ kind: 'supergroup',
980
+ popped: 0,
981
+ done: false,
982
+ attempts: SWEEP_MAX_ATTEMPTS,
983
+ lastStatus: 'skipped-no-rights',
984
+ updatedAt: now,
985
+ }
986
+ const stillOwed: SweepCursor = {
987
+ chatId: '900000002',
988
+ kind: 'dm',
989
+ popped: 0,
990
+ done: false,
991
+ attempts: 2,
992
+ updatedAt: now,
993
+ }
994
+
995
+ const keys = pruneSweepCursors([discharged, forfeited, stillOwed]).map((c) => c.chatId)
996
+
997
+ expect(keys).not.toContain(DM) // discharged → reseeded (dropped)
998
+ expect(keys).toContain(GROUP) // forfeited no-rights → RETAINED (no re-burn)
999
+ expect(keys).toContain('900000002') // still owed → retained untouched
1000
+ })
1001
+
1002
+ it('clears discharged rows for EVERY surface — dm, forum-topic, supergroup', () => {
1003
+ // The prune must be kind-agnostic: a stuck `done` on a topic or a channel is
1004
+ // the same regression as on a DM, so none may be left short-circuiting.
1005
+ const rows: SweepCursor[] = [
1006
+ { chatId: '1', kind: 'dm', popped: 1, done: true, attempts: 1, updatedAt: now },
1007
+ { chatId: '2', kind: 'forum-topic', threadId: 5, popped: 1, done: true, attempts: 1, updatedAt: now },
1008
+ { chatId: '3', kind: 'supergroup', popped: 1, done: true, attempts: 1, updatedAt: now },
1009
+ ]
1010
+ expect(pruneSweepCursors(rows)).toEqual([])
1011
+ })
1012
+ })
1013
+
1014
+ describe('stale-pin sweep — re-drain after the boot-seed prune (#3953)', () => {
1015
+ it('re-drains a DM whose obligation was discharged in a prior session', async () => {
1016
+ const fs = memFs()
1017
+ const path = '/state/stale-pin-sweep.json'
1018
+
1019
+ // Session 1: an orphan bot pin is drained and the obligation discharged.
1020
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [11] })).sweepTarget({ chatId: DM })
1021
+ expect(s1.status).toBe('drained')
1022
+ expect(loadSweepCursors(path, fs).find((c) => c.chatId === DM)?.done).toBe(true)
1023
+
1024
+ // A NEW orphan pin leaks in after that drain.
1025
+ // Fresh process, no prune: the sweep short-circuits on the stale `done` —
1026
+ // the #3953 regression, verbatim. The orphan is left pinned.
1027
+ const stuckChat = fakeChat({ stack: [99] })
1028
+ const stuck = await sweeperOver(fs, path, stuckChat).sweepTarget({ chatId: DM })
1029
+ expect(stuck.status).toBe('already-drained')
1030
+ expect(stuck.popped).toBe(0)
1031
+ expect(stuckChat.stack).toEqual([99])
1032
+
1033
+ // Reboot WITH the boot-seed prune: the discharged row is reseeded, so the
1034
+ // next sweep re-evaluates the chat LIVE and reaps the orphan.
1035
+ bootSeedPrune(fs, path)
1036
+ const rebootChat = fakeChat({ stack: [99] })
1037
+ const redrain = await sweeperOver(fs, path, rebootChat).sweepTarget({ chatId: DM })
1038
+ expect(redrain.status).toBe('drained')
1039
+ expect(redrain.popped).toBe(1)
1040
+ expect(rebootChat.stack).toEqual([])
1041
+ })
1042
+
1043
+ it('re-drains a supergroup (channel-class) discharged in a prior session', async () => {
1044
+ const fs = memFs()
1045
+ const path = '/state/stale-pin-sweep.json'
1046
+
1047
+ // Session 1: the one recorded orphan is reaped, obligation discharged.
1048
+ const s1 = await sweeperOver(fs, path, fakeChat({ stack: [77], canPin: true }), {
1049
+ recordedPinIds: [77],
1050
+ }).sweepTarget({ chatId: GROUP })
1051
+ expect(s1.status).toBe('drained')
1052
+ expect(loadSweepCursors(path, fs).find((c) => c.chatId === GROUP)?.done).toBe(true)
1053
+
1054
+ // A fresh recorded orphan leaks in; a fresh process short-circuits on `done`.
1055
+ const stuckChat = fakeChat({ stack: [88], canPin: true })
1056
+ const stuck = await sweeperOver(fs, path, stuckChat, { recordedPinIds: [88] }).sweepTarget({
1057
+ chatId: GROUP,
1058
+ })
1059
+ expect(stuck.status).toBe('already-drained')
1060
+ expect(stuckChat.stack).toEqual([88])
1061
+
1062
+ // Reboot + prune: the supergroup row is reseeded and re-swept.
1063
+ bootSeedPrune(fs, path)
1064
+ const rebootChat = fakeChat({ stack: [88], canPin: true })
1065
+ const redrain = await sweeperOver(fs, path, rebootChat, { recordedPinIds: [88] }).sweepTarget({
1066
+ chatId: GROUP,
1067
+ })
1068
+ expect(redrain.status).toBe('drained')
1069
+ expect(rebootChat.stack).toEqual([])
1070
+ })
1071
+ })
@@ -28,6 +28,14 @@
28
28
  * is visibly running; nulling `currentTurn` there would darken the very feed the
29
29
  * user is watching. A turn with no in-flight tool is unaffected.
30
30
  *
31
+ * #4330 caveat: an activity-card render that LANDED on Telegram within
32
+ * `CARD_RENDER_FRESH_MS` (stamped via `noteCardRender` from the card-drain
33
+ * transport) likewise DEFERS the terminal unwedge, bounded by the same hard
34
+ * ceiling — a visibly-updating pinned "→ Working…" card is user-visible
35
+ * activity, and killing the turn under it is a false positive. It never
36
+ * resets the clock: the framework heartbeat climbs the card on pure wall
37
+ * clock even on a hung turn, so a reset would pin a dead turn forever.
38
+ *
31
39
  * Terminal action, once per turn:
32
40
  *
33
41
  * t=0 startTurn() — silence clock starts at turnStartedAt
@@ -110,6 +118,21 @@ export interface SilencePokeState {
110
118
  * silence clock — a real reply / feed edit still does that; this only
111
119
  * gates the terminal teardown. */
112
120
  sawBashThisTurn: boolean
121
+ /**
122
+ * #4330: wall-clock ms of the last activity-card render (open or in-place
123
+ * edit) that actually LANDED on Telegram for this turn — the pinned
124
+ * "→ Working… · Nm · N tools" status card the user is watching. Stamped by
125
+ * `noteCardRender` from the card-drain transport site
126
+ * (`gateway/narrative-lane.ts` drainActivitySummary), which covers every
127
+ * producer: tool labels, narrative SHOWs, the liveness open, and the
128
+ * framework heartbeat climb. Deliberately NOT a clock reset — the heartbeat
129
+ * climb re-renders on pure wall clock, so a hung turn's card keeps
130
+ * "updating" and an unbounded reset would pin a dead turn forever (the
131
+ * #1556 class). Instead the tick() DEFERS the terminal fallback while the
132
+ * card moved within `CARD_RENDER_FRESH_MS`, bounded by
133
+ * `fallbackHardCeiling` exactly like the in-flight-tool / compaction
134
+ * defers. null until the first card render of the turn. */
135
+ lastCardRenderAt: number | null
113
136
  /**
114
137
  * #3519 sharpen: claude-CLI background shells PROVEN alive right now. A
115
138
  * shell is added on its launch marker (structured `backgroundTaskId`, via
@@ -153,6 +176,16 @@ export const DEFAULT_THRESHOLDS: ThresholdsMs = {
153
176
 
154
177
  export const DEFAULT_POLL_INTERVAL_MS = 5_000
155
178
 
179
+ /**
180
+ * #4330 — how recent the last landed activity-card render must be for the
181
+ * card to count as "actively updating in front of the user". The framework
182
+ * heartbeat edits the card every ~6s (FEED_HEARTBEAT_MIN_STALE_MS); 30s
183
+ * tolerates several shed/coalesced cosmetic edits under flood-control
184
+ * backoff while still lapsing quickly once the card genuinely stops moving
185
+ * (turn ended → card cleared, or the drain wedged).
186
+ */
187
+ export const CARD_RENDER_FRESH_MS = 30_000
188
+
156
189
  export interface FrameworkFallbackContext {
157
190
  key: string
158
191
  chatId: string
@@ -326,6 +359,7 @@ export function startTurn(key: string, now: number): void {
326
359
  fallbackFired: false,
327
360
  floorFired: false,
328
361
  inFlightTools: new Map(),
362
+ lastCardRenderAt: null,
329
363
  sawBashThisTurn: false,
330
364
  aliveShells: new Set(),
331
365
  })
@@ -397,6 +431,36 @@ export function noteProduction(key: string, now: number): void {
397
431
  s.fallbackFired = false
398
432
  }
399
433
 
434
+ /**
435
+ * #4330: record an activity-card render (open or in-place edit) that actually
436
+ * LANDED on Telegram for `key` — the pinned "→ Working…" status card visibly
437
+ * moved. Called from the card-drain transport site
438
+ * (`gateway/narrative-lane.ts` drainActivitySummary) after a successful
439
+ * sendRichMessage / non-shed editMessageText, so it covers every card
440
+ * producer — tool labels, narrative SHOWs, the liveness open, AND the
441
+ * framework heartbeat climb (`feedHeartbeatTick`), which is exactly the path
442
+ * that has no `noteProduction` site.
443
+ *
444
+ * Deliberately does NOT reset the silence clock (`lastOutboundAt` untouched,
445
+ * `fallbackFired` untouched): the heartbeat climb renders on pure wall clock
446
+ * (`now - turn.startedAt`), so a genuinely hung turn's card keeps climbing
447
+ * forever — counting that as production would keep a dead turn alive
448
+ * indefinitely (the #1556 dangling-turn class the stream-render tool-label
449
+ * comment warns about). Instead, tick() DEFERS the terminal fallback while
450
+ * `now - lastCardRenderAt < CARD_RENDER_FRESH_MS`, bounded by
451
+ * `fallbackHardCeiling` — same semantics as the #1292/#3519/#4058 defers.
452
+ * Model-driven card progress (a NEW tool label, a foreground sub-agent nested
453
+ * render, a draft update) additionally resets the clock via the existing
454
+ * `noteProduction` sites; this stamp is the floor beneath them.
455
+ *
456
+ * No-op when the kill switch is on or the key has no live turn.
457
+ */
458
+ export function noteCardRender(key: string, now: number): void {
459
+ const s = state.get(key)
460
+ if (s == null) return
461
+ s.lastCardRenderAt = now
462
+ }
463
+
400
464
  /**
401
465
  * Record a `thinking` session event. Used to pick "still thinking…" vs
402
466
  * "still working…" wording for the 300s framework fallback.
@@ -801,6 +865,22 @@ function tick(now: number): void {
801
865
  // gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
802
866
  // scopes the TOOL defers; compaction has no tool in flight.
803
867
  if (activeDeps.isCompactionInFlight?.(key) === true) continue
868
+ // #4330 — the pinned activity card is visibly updating. A card render
869
+ // (open or edit) landed on Telegram within CARD_RENDER_FRESH_MS, so
870
+ // the user is watching a live "→ Working… · Nm · N tools" surface —
871
+ // the "silent to the user" premise does not hold, and tearing the
872
+ // turn down would kill the very card they are watching, then re-ask
873
+ // their message under it. DEFER (clock untouched, fallbackFired
874
+ // unset, re-checked next tick), bounded by the enclosing
875
+ // `underCeiling` guard: the heartbeat climb keeps a hung turn's card
876
+ // moving on pure wall clock, so a truly wedged turn still unwedges at
877
+ // the hard ceiling. Like the #4058 compaction defer, deliberately NOT
878
+ // gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
879
+ // scopes the TOOL defers; a card render is not a tool signal.
880
+ if (
881
+ s.lastCardRenderAt != null &&
882
+ now - s.lastCardRenderAt < CARD_RENDER_FRESH_MS
883
+ ) continue
804
884
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
805
885
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
806
886
  if (activeDeps.isLegitimatelyWorking(key)) continue
@@ -161,6 +161,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
161
161
  order.push('queued-cards')
162
162
  },
163
163
  enableSweep: () => order.push('enable-sweep'),
164
+ seedPruneSweepCursors: () => order.push('seed-prune'),
164
165
  sweepTarget: async (t) => {
165
166
  order.push(`sweep:${t.chatId}:${t.threadId ?? '-'}`)
166
167
  },
@@ -177,6 +178,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
177
178
  'status-pins',
178
179
  'activity-cards',
179
180
  'queued-cards',
181
+ 'seed-prune',
180
182
  'enable-sweep',
181
183
  'sweep:900000001:-',
182
184
  ])
@@ -194,6 +196,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
194
196
  'scan',
195
197
  'status-pins',
196
198
  'queued-cards',
199
+ 'seed-prune',
197
200
  'enable-sweep',
198
201
  'sweep:900000001:-',
199
202
  ])
@@ -210,7 +213,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
210
213
  queuedCardReaper: boom,
211
214
  })
212
215
  await runBootPinSweepSteps(d.deps)
213
- expect(d.order).toEqual(['scan', 'enable-sweep', 'sweep:900000001:-'])
216
+ expect(d.order).toEqual(['scan', 'seed-prune', 'enable-sweep', 'sweep:900000001:-'])
214
217
  })
215
218
 
216
219
  it('a failing store scan still lets the reapers and the DM enable run', async () => {
@@ -221,7 +224,13 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
221
224
  })
222
225
  await runBootPinSweepSteps(d.deps)
223
226
  // No targets to sweep, but nothing behind the scan is stranded.
224
- expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-sweep'])
227
+ expect(d.order).toEqual([
228
+ 'status-pins',
229
+ 'activity-cards',
230
+ 'queued-cards',
231
+ 'seed-prune',
232
+ 'enable-sweep',
233
+ ])
225
234
  expect(d.logs.join('')).toContain("step 'sweep-target-scan' failed: ENOENT")
226
235
  })
227
236
 
@@ -272,6 +281,7 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
272
281
  order.push('queued-cards')
273
282
  },
274
283
  enableSweep: () => order.push('enable-sweep'),
284
+ seedPruneSweepCursors: () => {},
275
285
  sweepTarget: async () => {},
276
286
  log: () => {
277
287
  throw new Error('EPIPE')
@@ -296,6 +306,9 @@ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
296
306
  enableSweep: () => {
297
307
  throw new Error('boom')
298
308
  },
309
+ seedPruneSweepCursors: () => {
310
+ throw new Error('boom')
311
+ },
299
312
  sweepTarget: boom,
300
313
  log: () => {},
301
314
  }),
@@ -0,0 +1,300 @@
1
+ /**
2
+ * #4330 — a visibly-updating pinned activity card must not be treated as
3
+ * silence by the 300s terminal fallback.
4
+ *
5
+ * The bug (user-reported, with screenshot): the framework fired the terminal
6
+ * unwedge — "⚠️ no output for 5 min — the framework ended that stalled turn"
7
+ * — on a HEALTHY turn whose pinned "→ Working… · Nm · N tools" status card
8
+ * WAS actively updating the whole time. The card edits are driven by
9
+ * `feedHeartbeatTick` (the 0-label climb / labelled-step elapsed re-render)
10
+ * through `drainActivitySummary`'s editMessageText, and that transport path
11
+ * had NO `noteProduction`/`noteOutbound` site — deliberately, because the
12
+ * heartbeat climbs on pure wall clock and an unbounded clock RESET would keep
13
+ * a genuinely hung turn alive forever (the #1556 class). So when every defer
14
+ * signal (in-flight tool, pending async dispatch, alive shells, compaction)
15
+ * read false at the 300s tick, the fallback tore down the very card the user
16
+ * was watching and re-asked their message.
17
+ *
18
+ * The fix: `drainActivitySummary` stamps `silencePoke.noteCardRender` on
19
+ * every card render that actually lands (open or non-shed edit); tick()
20
+ * DEFERS the terminal fallback while the last landed render is younger than
21
+ * `CARD_RENDER_FRESH_MS`, bounded by `fallbackHardCeiling` exactly like the
22
+ * #1292/#3519/#4058 defers. These tests drive the REAL tick loop (and, for
23
+ * the wiring case, the REAL narrative-lane transport) and assert outcomes:
24
+ * - card renders arriving faster than the fresh window → NO fire past 300s;
25
+ * - renders that keep coming forever → STILL fires at the hard ceiling
26
+ * (a hung turn's heartbeat keeps climbing, so the net must stay bounded);
27
+ * - a genuinely silent turn (no renders, no tool, no reply) → STILL fires
28
+ * at 300s (the safety net is not disabled);
29
+ * - renders that STOP → the fallback fires once silence ≥ threshold and
30
+ * the last render has aged out (no unearned extension);
31
+ * - wiring: a card edit through the REAL `createNarrativeLane` drain
32
+ * defers the fallback for its status key.
33
+ */
34
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
35
+ import { readFileSync } from 'node:fs'
36
+ import { resolve } from 'node:path'
37
+ import { tmpdir } from 'node:os'
38
+ import { createNarrativeLane } from '../gateway/narrative-lane.js'
39
+ import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
40
+ import * as silencePoke from '../silence-poke.js'
41
+ import {
42
+ startTurn,
43
+ noteCardRender,
44
+ __tickForTests,
45
+ __setDepsForTests,
46
+ __getStateForTests,
47
+ __resetAllForTests,
48
+ DEFAULT_THRESHOLDS,
49
+ CARD_RENDER_FRESH_MS,
50
+ type SilencePokeMetric,
51
+ type FrameworkFallbackContext,
52
+ type ThresholdsMs,
53
+ } from '../silence-poke.js'
54
+
55
+ const HARD_CEILING = 900_000 // SILENCE_FALLBACK_HARD_MS default
56
+
57
+ interface TestFixtures {
58
+ emitted: SilencePokeMetric[]
59
+ fallbacks: FrameworkFallbackContext[]
60
+ }
61
+
62
+ function setupDeps(opts?: { thresholdsMs?: ThresholdsMs }): TestFixtures {
63
+ const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
64
+ __setDepsForTests({
65
+ emitMetric: (e) => fixtures.emitted.push(e),
66
+ onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
67
+ thresholdsMs: opts?.thresholdsMs
68
+ ?? { ...DEFAULT_THRESHOLDS, fallbackHardCeiling: HARD_CEILING },
69
+ // Mirror production wiring: the callback path is active (liveness-wiring
70
+ // always wires isLegitimatelyWorking), returning false = "no tool work".
71
+ isLegitimatelyWorking: () => false,
72
+ })
73
+ return fixtures
74
+ }
75
+
76
+ beforeEach(() => {
77
+ __resetAllForTests()
78
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
79
+ delete process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS
80
+ })
81
+
82
+ afterEach(() => {
83
+ __resetAllForTests()
84
+ })
85
+
86
+ describe('silence-poke — #4330 card-render defer (outcome)', () => {
87
+ it('a turn whose ONLY output is card renders faster than the fresh window is NOT torn down past 300s', () => {
88
+ const fx = setupDeps()
89
+ startTurn('chat:1', 0)
90
+ // The heartbeat edits the card every ~6s; simulate a card that keeps
91
+ // visibly updating for 10 minutes with no reply, no tool, no draft.
92
+ for (let t = 6_000; t <= 600_000; t += 6_000) {
93
+ noteCardRender('chat:1', t)
94
+ __tickForTests(t)
95
+ }
96
+ // Pre-fix this fired at the first tick with silence >= 300_000 — the
97
+ // exact "framework ended that stalled turn" false positive.
98
+ expect(fx.fallbacks).toHaveLength(0)
99
+ expect(fx.emitted).toHaveLength(0)
100
+ })
101
+
102
+ it('card renders that keep coming forever STILL fire the fallback at the hard ceiling (bounded defer)', () => {
103
+ const fx = setupDeps()
104
+ startTurn('chat:2', 0)
105
+ // A hung turn: the framework heartbeat keeps climbing the card on pure
106
+ // wall clock even though the model is dead. The defer must stay bounded.
107
+ let fired: number | null = null
108
+ for (let t = 6_000; t <= 1_200_000; t += 6_000) {
109
+ noteCardRender('chat:2', t)
110
+ __tickForTests(t)
111
+ if (fx.fallbacks.length > 0 && fired == null) fired = t
112
+ }
113
+ expect(fx.fallbacks).toHaveLength(1)
114
+ expect(fired).not.toBeNull()
115
+ expect(fired!).toBeGreaterThanOrEqual(HARD_CEILING)
116
+ // Fires promptly AT the ceiling, not some window after it.
117
+ expect(fired!).toBeLessThanOrEqual(HARD_CEILING + 6_000)
118
+ })
119
+
120
+ it('a genuinely silent turn (no card renders, no tool, no reply) STILL fires at 300s — the net is intact', () => {
121
+ const fx = setupDeps()
122
+ startTurn('chat:3', 0)
123
+ __tickForTests(299_000)
124
+ expect(fx.fallbacks).toHaveLength(0)
125
+ __tickForTests(300_000)
126
+ expect(fx.fallbacks).toHaveLength(1)
127
+ expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent' })
128
+ })
129
+
130
+ it('card renders that STOP do not extend the window — fallback fires once the last render ages out', () => {
131
+ const fx = setupDeps()
132
+ startTurn('chat:4', 0)
133
+ // Card updated until t=100s (turn wedged after; heartbeat drain stuck /
134
+ // card torn down), then nothing.
135
+ for (let t = 6_000; t <= 100_000; t += 6_000) {
136
+ noteCardRender('chat:4', t)
137
+ __tickForTests(t)
138
+ }
139
+ // At 305s: silence (never reset by noteCardRender) is 305s >= 300s and
140
+ // the last render is 200s+ old — well past CARD_RENDER_FRESH_MS. Fires.
141
+ __tickForTests(305_000)
142
+ expect(fx.fallbacks).toHaveLength(1)
143
+ expect(fx.fallbacks[0]!.silenceMs).toBe(305_000)
144
+ })
145
+
146
+ it('noteCardRender never resets the silence clock or re-arms a fired fallback', () => {
147
+ setupDeps()
148
+ startTurn('chat:5', 0)
149
+ noteCardRender('chat:5', 250_000)
150
+ const s = __getStateForTests('chat:5')!
151
+ expect(s.lastOutboundAt).toBeNull() // clock untouched — defer only
152
+ expect(s.lastCardRenderAt).toBe(250_000)
153
+ expect(s.fallbackFired).toBe(false)
154
+ })
155
+ })
156
+
157
+ // ── wiring proof: the REAL narrative-lane card drain stamps the defer ─────
158
+
159
+ const CHAT = '1001'
160
+
161
+ function makeLane() {
162
+ const noop = () => {}
163
+ let nextId = 3000
164
+ const calls: Array<{ method: string }> = []
165
+ const api = {
166
+ sendRichMessage: async () => {
167
+ calls.push({ method: 'sendRichMessage' })
168
+ return { message_id: ++nextId }
169
+ },
170
+ editMessageText: async () => {
171
+ calls.push({ method: 'editMessageText' })
172
+ return {}
173
+ },
174
+ deleteMessage: async () => true,
175
+ }
176
+ const fakeEA = {
177
+ mayDrain: () => true,
178
+ openOrEditCard: (_p: string, fn: () => void) => fn(),
179
+ finalizeCard: (fn: () => void) => fn(),
180
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
181
+ }
182
+ const deps = {
183
+ ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/silence-card-render-activity-cards.json`,
184
+ CLEAR_STATUS_ON_COMPLETION: false,
185
+ FEED_HEARTBEAT_ENABLED: false,
186
+ FEED_HEARTBEAT_MIN_STALE_MS: 6000,
187
+ FEED_LIVENESS_OPEN_ENABLED: false,
188
+ FEED_LIVENESS_OPEN_MS: 5000,
189
+ PIN_STATUS_WHILE_WORKING: false,
190
+ POST_ANSWER_LIVENESS_STALE_MS: 90000,
191
+ STATIC: false,
192
+ activeDraftStreams: new Map(),
193
+ activityCardPersistEnabled: false,
194
+ activityCardStoreFs: { readFileSync: () => '', writeFileSync: noop, mkdirSync: noop, renameSync: noop, unlinkSync: noop },
195
+ bot: { api },
196
+ cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
197
+ currentTurnMap: { get: () => null, byKey: new Map() },
198
+ earlyLivenessOpenTimers: new Map(),
199
+ emissionAuthorityFor: () => fakeEA,
200
+ feedOpenGateDeps: () => ({ hasOutboundDeliveredSince: () => false, historyEnabled: false, finalAnswerMinChars: 200 }),
201
+ getCurrentTurn: () => null,
202
+ reconcileStatusPin: noop,
203
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
204
+ // Same key shape silence-poke is driven with below.
205
+ statusKey: (c: string, t?: number | null) => `${c}:${t ?? ''}`,
206
+ } as unknown as NarrativeLaneDeps
207
+ const lane = createNarrativeLane(deps)
208
+ return { lane, calls }
209
+ }
210
+
211
+ function makeLaneTurn(lane: ReturnType<typeof createNarrativeLane>): CurrentTurn {
212
+ const turn = {
213
+ turnId: 'turn-card-render-1',
214
+ sessionChatId: CHAT,
215
+ sessionThreadId: undefined,
216
+ sourceMessageId: null,
217
+ registryKey: null,
218
+ startedAt: Date.now() - 3000,
219
+ currentModel: null,
220
+ totalTokens: 0,
221
+ labeledToolCount: 0,
222
+ mirrorLines: [] as string[],
223
+ foregroundSubAgents: new Map<string, string[]>(),
224
+ activityPendingRender: null as string | null,
225
+ activityLastSentRender: null as string | null,
226
+ activityMessageId: null as number | null,
227
+ activityInFlight: null as Promise<void> | null,
228
+ activityEverOpened: false,
229
+ activityDrainFailures: 0,
230
+ finalAnswerEverDelivered: false,
231
+ finalAnswerDelivered: false,
232
+ replyCalled: false,
233
+ capturedText: [] as string[],
234
+ lastReplyText: '',
235
+ answerStream: null,
236
+ liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
237
+ } as unknown as CurrentTurn
238
+ ;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
239
+ return turn
240
+ }
241
+
242
+ describe('silence-poke — #4330 wiring: the REAL card drain defers the fallback', () => {
243
+ it('a card render landed through drainActivitySummary defers a due fallback; without it the fallback fires', async () => {
244
+ const key = `${CHAT}:`
245
+ const now = Date.now()
246
+ // Real wall clock end-to-end: tiny fallback threshold, real
247
+ // CARD_RENDER_FRESH_MS (the lane stamps Date.now()).
248
+ const fx = setupDeps({
249
+ thresholdsMs: { fallback: 1_000, fallbackHardCeiling: HARD_CEILING },
250
+ })
251
+
252
+ // Control first: a turn past the threshold with NO card render fires.
253
+ startTurn(key, now - 5_000)
254
+ __tickForTests(now)
255
+ expect(fx.fallbacks).toHaveLength(1)
256
+
257
+ // Now the same shape, but a card render lands through the REAL lane
258
+ // transport before the tick — the fallback must be deferred.
259
+ __resetAllForTests()
260
+ const fx2 = setupDeps({
261
+ thresholdsMs: { fallback: 1_000, fallbackHardCeiling: HARD_CEILING },
262
+ })
263
+ startTurn(key, Date.now() - 5_000)
264
+ const { lane, calls } = makeLane()
265
+ const turn = makeLaneTurn(lane)
266
+ lane.showNarrativeStep(turn, 'Compiling the release notes')
267
+ await turn.activityInFlight
268
+ expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
269
+ const st = __getStateForTests(key)!
270
+ expect(st.lastCardRenderAt).not.toBeNull() // the drain stamped it
271
+ __tickForTests(Date.now())
272
+ expect(fx2.fallbacks).toHaveLength(0) // deferred: the card just moved
273
+
274
+ // And the defer stays bounded: past the hard ceiling it fires anyway,
275
+ // even with a render landed inside the fresh window at fire time.
276
+ const tFinal = Date.now() + HARD_CEILING + CARD_RENDER_FRESH_MS
277
+ noteCardRender(key, tFinal - 1_000) // card still "moving" at the ceiling
278
+ __tickForTests(tFinal)
279
+ expect(fx2.fallbacks).toHaveLength(1)
280
+ })
281
+ })
282
+
283
+ // ── structural: progress_update (a model-driven user-visible send inside the
284
+ // gateway IIFE, not instantiable in-process — same pattern as
285
+ // silence-liveness-wiring.test.ts) must reset the silence clock ──────────
286
+
287
+ describe('silence-poke — #4330 progress_update liveness wiring (structural)', () => {
288
+ it('the progress_update handler calls silencePoke.noteOutbound after the send lands', () => {
289
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
290
+ const start = "if (!args.chat_id) throw new Error('progress_update: chat_id is required')"
291
+ const end = '`ask_user` MCP tool'
292
+ const block = (gatewaySrc.split(start)[1] ?? '').split(end)[0] ?? ''
293
+ expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler
294
+ // A progress_update is a fresh user-visible outbound the model authored —
295
+ // it must reset the 300s silence clock exactly like a reply send does
296
+ // (outbound-send-path.ts). Pre-#4330 only signalTracker was ticked, so a
297
+ // turn narrating solely via progress_update was torn down as "silent".
298
+ expect(block).toMatch(/silencePoke\.noteOutbound\(key, Date\.now\(\)\)/)
299
+ })
300
+ })