switchroom 0.12.21 → 0.12.22
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
CHANGED
|
@@ -47247,8 +47247,8 @@ var {
|
|
|
47247
47247
|
} = import__.default;
|
|
47248
47248
|
|
|
47249
47249
|
// src/build-info.ts
|
|
47250
|
-
var VERSION = "0.12.
|
|
47251
|
-
var COMMIT_SHA = "
|
|
47250
|
+
var VERSION = "0.12.22";
|
|
47251
|
+
var COMMIT_SHA = "332e23e";
|
|
47252
47252
|
|
|
47253
47253
|
// src/cli/agent.ts
|
|
47254
47254
|
init_source();
|
package/package.json
CHANGED
|
@@ -47126,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47126
47126
|
}
|
|
47127
47127
|
|
|
47128
47128
|
// ../src/build-info.ts
|
|
47129
|
-
var VERSION = "0.12.
|
|
47130
|
-
var COMMIT_SHA = "
|
|
47131
|
-
var COMMIT_DATE = "2026-05-20T02:
|
|
47132
|
-
var LATEST_PR =
|
|
47133
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
47129
|
+
var VERSION = "0.12.22";
|
|
47130
|
+
var COMMIT_SHA = "332e23e";
|
|
47131
|
+
var COMMIT_DATE = "2026-05-20T02:55:00Z";
|
|
47132
|
+
var LATEST_PR = 1574;
|
|
47133
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
47134
47134
|
|
|
47135
47135
|
// gateway/boot-version.ts
|
|
47136
47136
|
function formatRelativeAgo(iso) {
|
|
@@ -51275,6 +51275,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
|
|
|
51275
51275
|
return;
|
|
51276
51276
|
}
|
|
51277
51277
|
const inboundReceivedAt = Date.now();
|
|
51278
|
+
const turnInFlightAtReceipt = activeTurnStartedAt.size > 0;
|
|
51278
51279
|
const access = result.access;
|
|
51279
51280
|
const from = ctx.from;
|
|
51280
51281
|
const chat_id = String(ctx.chat.id);
|
|
@@ -51834,7 +51835,7 @@ ${preBlock(write.output)}`;
|
|
|
51834
51835
|
};
|
|
51835
51836
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
51836
51837
|
if (decideInboundDelivery({
|
|
51837
|
-
turnInFlight:
|
|
51838
|
+
turnInFlight: turnInFlightAtReceipt,
|
|
51838
51839
|
isSteering
|
|
51839
51840
|
}) === "buffer-until-idle") {
|
|
51840
51841
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
@@ -6637,6 +6637,39 @@ async function handleInbound(
|
|
|
6637
6637
|
// network RTT) but not a user-perceived end-to-end measurement.
|
|
6638
6638
|
const inboundReceivedAt = Date.now()
|
|
6639
6639
|
|
|
6640
|
+
// #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
|
|
6641
|
+
// BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
|
|
6642
|
+
// for THIS inbound. The #1556 delivery gate further down asks "is a
|
|
6643
|
+
// turn ALREADY in flight" — but if we read activeTurnStartedAt.size
|
|
6644
|
+
// at the gate, we see the entry this handler just wrote, and buffer
|
|
6645
|
+
// the very message that just started the turn. Symptom: every first
|
|
6646
|
+
// post-restart message in each thread was held 5 minutes until the
|
|
6647
|
+
// silence-poke fallback drained the buffer; the "5-min blank after
|
|
6648
|
+
// restart" wedge documented in
|
|
6649
|
+
// feedback_5min_restart_wedge_violates_vision.md.
|
|
6650
|
+
//
|
|
6651
|
+
// Why ONLY first-after-restart, not every steady-state message: the
|
|
6652
|
+
// fresh-turn branch fires only when `priorActive = activeStatusReactions.get(key)`
|
|
6653
|
+
// returns null (no controller currently running for this chat+thread).
|
|
6654
|
+
// In a live conversation, the controller from the previous turn
|
|
6655
|
+
// typically hasn't been cleared yet when the user's follow-up
|
|
6656
|
+
// arrives (or follow-ups arrive mid-turn, taking the queued path) —
|
|
6657
|
+
// so the else branch at ~7313 is skipped and the .set never fires.
|
|
6658
|
+
// A fresh container after restart has EMPTY `activeStatusReactions`,
|
|
6659
|
+
// so the very first message in each thread is guaranteed to enter
|
|
6660
|
+
// the fresh-turn branch and trigger the self-block.
|
|
6661
|
+
//
|
|
6662
|
+
// Why snapshot, not move-the-set(): the .set() at ~7357 is embedded
|
|
6663
|
+
// in a coupled init bundle (controller, msgIds, signalTracker.reset,
|
|
6664
|
+
// silencePoke.startTurn, the 👀 reaction emit). Moving only the .set
|
|
6665
|
+
// splits the bundle in ways future maintainers will drift; moving
|
|
6666
|
+
// the WHOLE bundle past the gate changes user-visible ack timing
|
|
6667
|
+
// (👀 wouldn't land until after the gate decides to deliver, hiding
|
|
6668
|
+
// an ack on the buffered path). The snapshot is the minimal precise
|
|
6669
|
+
// fix. Phase 2b's state-machine extraction will revisit this
|
|
6670
|
+
// structurally.
|
|
6671
|
+
const turnInFlightAtReceipt = activeTurnStartedAt.size > 0
|
|
6672
|
+
|
|
6640
6673
|
const access = result.access
|
|
6641
6674
|
const from = ctx.from!
|
|
6642
6675
|
const chat_id = String(ctx.chat!.id)
|
|
@@ -7546,9 +7579,15 @@ async function handleInbound(
|
|
|
7546
7579
|
// idle-drain flush it the instant claude goes idle, where the channel
|
|
7547
7580
|
// notification submits cleanly as a fresh turn. Steering messages are
|
|
7548
7581
|
// exempt — reaching claude mid-turn is the whole point of /steer.
|
|
7582
|
+
//
|
|
7583
|
+
// CRITICAL: turnInFlight reads the snapshot taken at receipt above,
|
|
7584
|
+
// not `activeTurnStartedAt.size > 0` live. The fresh-turn branch at
|
|
7585
|
+
// line ~7357 already populated the Map for THIS inbound's turn;
|
|
7586
|
+
// reading the live size here would self-block (see the comment on
|
|
7587
|
+
// turnInFlightAtReceipt for the wedge symptom this fixes).
|
|
7549
7588
|
if (
|
|
7550
7589
|
decideInboundDelivery({
|
|
7551
|
-
turnInFlight:
|
|
7590
|
+
turnInFlight: turnInFlightAtReceipt,
|
|
7552
7591
|
isSteering,
|
|
7553
7592
|
}) === 'buffer-until-idle'
|
|
7554
7593
|
) {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD scenario — always-on vision: first message after restart replies
|
|
3
|
+
* quickly, NOT 5 minutes later via silence-poke fallback.
|
|
4
|
+
*
|
|
5
|
+
* Vision (`reference/vision.md`, see [[project_vision_reanchor_human_assistants]]):
|
|
6
|
+
* agents are always-on specialist exec-assistants. A 5-min blank
|
|
7
|
+
* window on the first message after restart is what BROKEN feels
|
|
8
|
+
* like to a user trying to use their assistant.
|
|
9
|
+
*
|
|
10
|
+
* ## The wedge this guards against
|
|
11
|
+
*
|
|
12
|
+
* Pre-v0.12.22, every agent restart produced ~5 min blank on the
|
|
13
|
+
* first user message in each thread:
|
|
14
|
+
*
|
|
15
|
+
* 1. User sends msg → handleInbound runs the fresh-turn branch
|
|
16
|
+
* → activeTurnStartedAt.set(key, now) at gateway.ts:7357
|
|
17
|
+
* 2. The #1556 delivery gate further down (~7551) checks
|
|
18
|
+
* activeTurnStartedAt.size > 0 to decide if a turn is "already
|
|
19
|
+
* in flight" — sees the entry it just wrote → buffer-until-idle
|
|
20
|
+
* 3. Inbound stuck in pendingInboundBuffer. Bridge never sees it.
|
|
21
|
+
* Claude never replies. activeTurnStartedAt[key] stays set.
|
|
22
|
+
* 4. ~300s later silence-poke framework-fallback fires, drains
|
|
23
|
+
* the buffer, the reply finally lands — five minutes late.
|
|
24
|
+
*
|
|
25
|
+
* Documented in `feedback_5min_restart_wedge_violates_vision.md`.
|
|
26
|
+
* Fix is a one-line snapshot of the live size at receipt-time before
|
|
27
|
+
* the fresh-turn branch mutates the Map.
|
|
28
|
+
*
|
|
29
|
+
* ## What this UAT asserts
|
|
30
|
+
*
|
|
31
|
+
* After a deliberate restart, the FIRST message in a fresh thread
|
|
32
|
+
* gets a reply within a budget that excludes the silence-poke
|
|
33
|
+
* fallback floor (300s). Concretely we assert < 120s, which is
|
|
34
|
+
* generous for slow LLM replies but well below the wedge symptom.
|
|
35
|
+
*
|
|
36
|
+
* The test also makes a stricter observation log so a future
|
|
37
|
+
* regression that lands BETWEEN "wedge fixed" (~LLM latency) and
|
|
38
|
+
* "wedge present" (~5 min) is visible — e.g. some slow startup
|
|
39
|
+
* path that takes 60-90s would pass the contract but bear
|
|
40
|
+
* investigation.
|
|
41
|
+
*
|
|
42
|
+
* ## Why this scenario specifically
|
|
43
|
+
*
|
|
44
|
+
* - `smoke-dm-reply.test.ts` covers steady-state inbound→reply but
|
|
45
|
+
* does NOT restart the agent first, so the wedge surfaces as a
|
|
46
|
+
* "first message is slow" pattern that the smoke test would
|
|
47
|
+
* silently absorb on warm runs.
|
|
48
|
+
* - `silent-end-recovery-dm.test.ts` covers mid-turn silent-end →
|
|
49
|
+
* no-reply, but at 6 min budget — too generous to catch the
|
|
50
|
+
* 5-min wedge as a regression.
|
|
51
|
+
* - This is the FIRST UAT to explicitly tie the assertion to the
|
|
52
|
+
* "always-on" vision and measure first-after-restart TTFO.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
56
|
+
import { execSync } from "node:child_process";
|
|
57
|
+
import { spinUp } from "../harness.js";
|
|
58
|
+
|
|
59
|
+
const AGENT = "test-harness";
|
|
60
|
+
|
|
61
|
+
// Budget for the marker-safe restart itself (per
|
|
62
|
+
// feedback_agent_restart_needs_sudo_when_running.md, restart blocks
|
|
63
|
+
// ~30s as the gateway's bridge reattaches).
|
|
64
|
+
const RESTART_BUDGET_MS = 90_000;
|
|
65
|
+
|
|
66
|
+
// Hard contract: first-after-restart reply must land in under 2 min.
|
|
67
|
+
// This is generous for slow LLM replies but well below the 5-min
|
|
68
|
+
// silence-poke fallback floor — a regression of the #1556 wedge
|
|
69
|
+
// would trip on TTFO ≥ 300s and fail the test.
|
|
70
|
+
const HARD_REPLY_BUDGET_MS = 120_000;
|
|
71
|
+
|
|
72
|
+
// Vision-aligned target: real expectation is well under 30s on a
|
|
73
|
+
// healthy fleet. A pass between 30-120s is yellow — covered by the
|
|
74
|
+
// contract but worth logging for forensic visibility.
|
|
75
|
+
const VISION_REPLY_BUDGET_MS = 30_000;
|
|
76
|
+
|
|
77
|
+
function canShellSudo(): boolean {
|
|
78
|
+
try {
|
|
79
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function restartAgent(name: string): void {
|
|
87
|
+
// Marker-safe restart per memory feedback_compose_rollout.md +
|
|
88
|
+
// feedback_agent_restart_needs_sudo_when_running.md. Apply step
|
|
89
|
+
// self-elevates internally; restart needs the wrapper. We don't
|
|
90
|
+
// call apply here — the agent scaffolds are already current; only
|
|
91
|
+
// the in-memory state needs to reset.
|
|
92
|
+
execSync(
|
|
93
|
+
`sudo -n env PATH=$PATH HOME=$HOME switchroom agent restart ${name} --force`,
|
|
94
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: RESTART_BUDGET_MS },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// This scenario requires NOPASSWD sudo + the switchroom CLI on PATH on
|
|
99
|
+
// the harness host. Skip on CI runners that don't expose those.
|
|
100
|
+
const sudoOk = canShellSudo();
|
|
101
|
+
|
|
102
|
+
(sudoOk ? describe : describe.skip)(
|
|
103
|
+
"uat: always-on after restart",
|
|
104
|
+
() => {
|
|
105
|
+
beforeAll(() => {
|
|
106
|
+
restartAgent(AGENT);
|
|
107
|
+
// Brief settle so the bridge sidecar finishes its reattach
|
|
108
|
+
// before we send the first inbound. The bridge-register log
|
|
109
|
+
// line is the earliest the agent can accept inbound.
|
|
110
|
+
return new Promise((r) => setTimeout(r, 5_000));
|
|
111
|
+
}, RESTART_BUDGET_MS + 10_000);
|
|
112
|
+
|
|
113
|
+
it(
|
|
114
|
+
"first message after fresh restart → reply within 2 min (NOT the 5-min wedge)",
|
|
115
|
+
async () => {
|
|
116
|
+
const sc = await spinUp({ agent: AGENT });
|
|
117
|
+
try {
|
|
118
|
+
const sendStart = Date.now();
|
|
119
|
+
await sc.sendDM("ping — JTBD always-on UAT");
|
|
120
|
+
|
|
121
|
+
const firstReply = await sc.expectMessage(/\S/, {
|
|
122
|
+
from: "bot",
|
|
123
|
+
timeout: HARD_REPLY_BUDGET_MS,
|
|
124
|
+
});
|
|
125
|
+
const ttfo = Date.now() - sendStart;
|
|
126
|
+
|
|
127
|
+
expect(firstReply.text.length).toBeGreaterThan(0);
|
|
128
|
+
|
|
129
|
+
// HARD CONTRACT: the wedge symptom is 300s+ TTFO. Anything
|
|
130
|
+
// ≥ HARD_REPLY_BUDGET_MS (120s) flags a regression of the
|
|
131
|
+
// #1556 self-blocking gate.
|
|
132
|
+
if (ttfo >= HARD_REPLY_BUDGET_MS) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`[always-on] first-post-restart reply took ${ttfo}ms — ` +
|
|
135
|
+
`matches the #1556 wedge symptom (5-min silence-poke fallback). ` +
|
|
136
|
+
`Vision broken; see feedback_5min_restart_wedge_violates_vision.md`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
expect(ttfo).toBeLessThan(HARD_REPLY_BUDGET_MS);
|
|
140
|
+
|
|
141
|
+
// Yellow-band log: passes the contract but degraded from the
|
|
142
|
+
// vision target. Worth investigating if this fires regularly.
|
|
143
|
+
if (ttfo >= VISION_REPLY_BUDGET_MS) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[always-on] first-post-restart TTFO=${ttfo}ms — passed ` +
|
|
146
|
+
`contract (${HARD_REPLY_BUDGET_MS}ms) but slower than the ` +
|
|
147
|
+
`vision target (${VISION_REPLY_BUDGET_MS}ms). Forensic flag.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
await sc.tearDown();
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
HARD_REPLY_BUDGET_MS + 10_000,
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
);
|