switchroom 0.20.4 → 0.20.5
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/cli/switchroom.js +5 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +27 -5
- package/telegram-plugin/gateway/gateway.ts +2 -0
- package/telegram-plugin/gateway/narrative-lane.ts +15 -0
- package/telegram-plugin/silence-poke.ts +80 -0
- package/telegram-plugin/tests/silence-poke-card-render.test.ts +300 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.20.
|
|
2123
|
+
var VERSION = "0.20.5", COMMIT_SHA = "fc40cc8e";
|
|
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.
|
|
21435
|
+
var VERSION = "0.20.5";
|
|
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
|
+
"version": "0.20.5",
|
|
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
|
|
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
|
|
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);
|
|
@@ -102409,10 +102428,10 @@ function startOutboxSweep(deps) {
|
|
|
102409
102428
|
}
|
|
102410
102429
|
|
|
102411
102430
|
// ../src/build-info.ts
|
|
102412
|
-
var VERSION2 = "0.20.
|
|
102413
|
-
var COMMIT_SHA = "
|
|
102414
|
-
var COMMIT_DATE = "2026-08-
|
|
102415
|
-
var LATEST_PR =
|
|
102431
|
+
var VERSION2 = "0.20.5";
|
|
102432
|
+
var COMMIT_SHA = "fc40cc8e";
|
|
102433
|
+
var COMMIT_DATE = "2026-08-04T04:03:52Z";
|
|
102434
|
+
var LATEST_PR = 4338;
|
|
102416
102435
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
102417
102436
|
|
|
102418
102437
|
// gateway/boot-version.ts
|
|
@@ -109926,6 +109945,9 @@ async function executeProgressUpdate(args) {
|
|
|
109926
109945
|
try {
|
|
109927
109946
|
noteSignal(key, Date.now());
|
|
109928
109947
|
} catch {}
|
|
109948
|
+
try {
|
|
109949
|
+
noteOutbound2(key, Date.now());
|
|
109950
|
+
} catch {}
|
|
109929
109951
|
return {
|
|
109930
109952
|
content: [
|
|
109931
109953
|
{
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
+
})
|