switchroom 0.19.37 → 0.19.38
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 +22 -9
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +13 -6
- package/telegram-plugin/gateway/gateway.ts +12 -4
- package/telegram-plugin/gateway/handback-preturn-signal.ts +52 -3
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +67 -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.19.
|
|
2123
|
+
var VERSION = "0.19.38", COMMIT_SHA = "9ec88983";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -45994,13 +45994,15 @@ var init_install_cron = () => {};
|
|
|
45994
45994
|
function inParts(memories) {
|
|
45995
45995
|
return Math.round(memories * PARTS_PER_MEMORY);
|
|
45996
45996
|
}
|
|
45997
|
-
var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
|
|
45997
|
+
var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, RECALL_WALL_MS, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
|
|
45998
45998
|
var init_thresholds = __esm(() => {
|
|
45999
|
+
init_hindsight_recall_tunables();
|
|
45999
46000
|
MAX_SAMPLE_AGE_MS = 3 * 60 * 60000;
|
|
46000
46001
|
QUEUE_FLOOR = inParts(QUEUE_FLOOR_MEMORIES);
|
|
46001
46002
|
QUEUE_GROWTH_MIN_ABS = inParts(QUEUE_GROWTH_MIN_ABS_MEMORIES);
|
|
46002
46003
|
RENOTIFY_MS = 6 * 60 * 60 * 1000;
|
|
46003
46004
|
METRICS_MAX_BYTES = 16 * 1024 * 1024;
|
|
46005
|
+
RECALL_WALL_MS = resolveHindsightRecallTunables(undefined).parallelDeadlineSeconds * 1000;
|
|
46004
46006
|
CONSOLIDATION_AGE_WARN_S = 2 * 60 * 60;
|
|
46005
46007
|
CONSOLIDATION_AGE_PAGE_S = 12 * 60 * 60;
|
|
46006
46008
|
CONSOLIDATION_STREAK_RECENCY_S = 2 * 60 * 60;
|
|
@@ -105312,7 +105314,7 @@ function latencySuffix(r) {
|
|
|
105312
105314
|
return "";
|
|
105313
105315
|
const n = typeof r.elapsedConsidered === "number" ? r.elapsedConsidered : 0;
|
|
105314
105316
|
return `
|
|
105315
|
-
recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the
|
|
105317
|
+
recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the ${RECALL_WALL_MS}ms recall deadline leaves no healthy band below it)`;
|
|
105316
105318
|
}
|
|
105317
105319
|
function evaluateRecallOwnBankTimeout(ring) {
|
|
105318
105320
|
const signal = "recall-own-bank-timeout";
|
|
@@ -105911,14 +105913,25 @@ function runInstallCron(cronUser) {
|
|
|
105911
105913
|
`);
|
|
105912
105914
|
return 0;
|
|
105913
105915
|
}
|
|
105914
|
-
|
|
105916
|
+
var PREFERRED_RELAY_AGENT = "overlord";
|
|
105917
|
+
function orderRelayCandidates(candidates, preferred = PREFERRED_RELAY_AGENT) {
|
|
105918
|
+
const first = candidates.filter((c) => c.agent === preferred);
|
|
105919
|
+
const rest = candidates.filter((c) => c.agent !== preferred);
|
|
105920
|
+
return [...first, ...rest];
|
|
105921
|
+
}
|
|
105922
|
+
function collectRelayCandidates(agentsDir) {
|
|
105915
105923
|
const candidates = [];
|
|
105924
|
+
for (const name of readdirSync45(agentsDir).sort()) {
|
|
105925
|
+
const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
|
|
105926
|
+
if (existsSync112(sock))
|
|
105927
|
+
candidates.push({ agent: name, sock });
|
|
105928
|
+
}
|
|
105929
|
+
return orderRelayCandidates(candidates);
|
|
105930
|
+
}
|
|
105931
|
+
async function notifyOperator(agentsDir, text, log) {
|
|
105932
|
+
let candidates = [];
|
|
105916
105933
|
try {
|
|
105917
|
-
|
|
105918
|
-
const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
|
|
105919
|
-
if (existsSync112(sock))
|
|
105920
|
-
candidates.push({ agent: name, sock });
|
|
105921
|
-
}
|
|
105934
|
+
candidates = collectRelayCandidates(agentsDir);
|
|
105922
105935
|
} catch (e) {
|
|
105923
105936
|
log(`hindsight-watch: agents dir unreadable (${e.message})`);
|
|
105924
105937
|
}
|
|
@@ -21311,7 +21311,7 @@ function allocateAgentUid(name) {
|
|
|
21311
21311
|
}
|
|
21312
21312
|
|
|
21313
21313
|
// src/build-info.ts
|
|
21314
|
-
var VERSION = "0.19.
|
|
21314
|
+
var VERSION = "0.19.38";
|
|
21315
21315
|
|
|
21316
21316
|
// src/setup/hindsight-recall-tunables.ts
|
|
21317
21317
|
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.19.
|
|
4
|
+
"version": "0.19.38",
|
|
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": {
|
|
@@ -53405,6 +53405,12 @@ function createHandbackPreturnSignal(deps) {
|
|
|
53405
53405
|
entry.reapTimer = null;
|
|
53406
53406
|
if (entry.consumed)
|
|
53407
53407
|
return;
|
|
53408
|
+
if (deps.isClaudeBusy?.() === true) {
|
|
53409
|
+
log(`handback-preturn-signal: reap deferred key=${entry.statusKey} ` + `(claude busy \u2014 handback enqueued behind an in-flight turn, not orphaned)
|
|
53410
|
+
`);
|
|
53411
|
+
entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs);
|
|
53412
|
+
return;
|
|
53413
|
+
}
|
|
53408
53414
|
entry.consumed = true;
|
|
53409
53415
|
stopTypingUnlessTurnLive(entry, "orphan reap");
|
|
53410
53416
|
if (entry.activityMessageId != null) {
|
|
@@ -99672,10 +99678,10 @@ function startOutboxSweep(deps) {
|
|
|
99672
99678
|
}
|
|
99673
99679
|
|
|
99674
99680
|
// ../src/build-info.ts
|
|
99675
|
-
var VERSION2 = "0.19.
|
|
99676
|
-
var COMMIT_SHA = "
|
|
99677
|
-
var COMMIT_DATE = "2026-07-
|
|
99678
|
-
var LATEST_PR =
|
|
99681
|
+
var VERSION2 = "0.19.38";
|
|
99682
|
+
var COMMIT_SHA = "9ec88983";
|
|
99683
|
+
var COMMIT_DATE = "2026-07-30T09:50:08Z";
|
|
99684
|
+
var LATEST_PR = 4031;
|
|
99679
99685
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
99680
99686
|
|
|
99681
99687
|
// gateway/boot-version.ts
|
|
@@ -104885,7 +104891,7 @@ async function runMidSessionCardReaper() {
|
|
|
104885
104891
|
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
104886
104892
|
path: ACTIVITY_CARD_STORE_PATH,
|
|
104887
104893
|
fs: activityCardStoreFs,
|
|
104888
|
-
isLive: (record2) => topicKeys.has(record2.turnKey),
|
|
104894
|
+
isLive: (record2) => topicKeys.has(record2.turnKey) || handbackPreturnSignal.isPreTurnRecord(record2.turnKey) && isMachineInTurn(),
|
|
104889
104895
|
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
104890
104896
|
now,
|
|
104891
104897
|
finalizeCard: (record2) => {
|
|
@@ -107525,7 +107531,8 @@ var handbackPreturnSignal = createHandbackPreturnSignal({
|
|
|
107525
107531
|
if (live == null || live.endedAt != null)
|
|
107526
107532
|
return false;
|
|
107527
107533
|
return statusKey(live.sessionChatId, live.sessionThreadId) === key;
|
|
107528
|
-
}
|
|
107534
|
+
},
|
|
107535
|
+
isClaudeBusy: () => isMachineInTurn()
|
|
107529
107536
|
});
|
|
107530
107537
|
var memoryLegibilityStager = new MemoryLegibilityStager;
|
|
107531
107538
|
function sendMemoryLegibilityLine(event, chatId, threadId) {
|
|
@@ -8909,10 +8909,14 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
8909
8909
|
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
8910
8910
|
path: ACTIVITY_CARD_STORE_PATH,
|
|
8911
8911
|
fs: activityCardStoreFs,
|
|
8912
|
-
// A synthetic pre-turn
|
|
8913
|
-
//
|
|
8914
|
-
//
|
|
8915
|
-
|
|
8912
|
+
// A synthetic pre-turn key reads as ownerless here (self-reap is the fast
|
|
8913
|
+
// path; this is the crash backstop). EXCEPTION: a pre-turn handback still
|
|
8914
|
+
// enqueued behind an in-flight turn is NOT an orphan — keep it live while
|
|
8915
|
+
// claude is busy, else this 15-min backstop re-posts the false nudge the
|
|
8916
|
+
// self-reap gate removed. (Boot reaper stays ungated: boot is never mid-turn.)
|
|
8917
|
+
isLive: (record) =>
|
|
8918
|
+
topicKeys.has(record.turnKey) ||
|
|
8919
|
+
(handbackPreturnSignal.isPreTurnRecord(record.turnKey) && isMachineInTurn()),
|
|
8916
8920
|
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
8917
8921
|
now,
|
|
8918
8922
|
finalizeCard: (record) => {
|
|
@@ -13827,6 +13831,10 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
|
|
|
13827
13831
|
// dark-indicator bug). Flag-ON is already per-key; the check is a no-op there.
|
|
13828
13832
|
return statusKey(live.sessionChatId, live.sessionThreadId) === key
|
|
13829
13833
|
},
|
|
13834
|
+
// Queue-state gate for the orphan reap (see the signal's `isClaudeBusy` doc):
|
|
13835
|
+
// while a turn is in flight the handback is enqueued behind it, not orphaned.
|
|
13836
|
+
// False for both `bridge_alive_idle` and `bridge_dead` → genuine orphan reaps.
|
|
13837
|
+
isClaudeBusy: () => isMachineInTurn(),
|
|
13830
13838
|
})
|
|
13831
13839
|
|
|
13832
13840
|
/**
|
|
@@ -54,9 +54,23 @@
|
|
|
54
54
|
* On adoption the durable record is re-keyed from its synthetic pre-turn key
|
|
55
55
|
* to the real `statusKey` so that teardown matches.
|
|
56
56
|
*
|
|
57
|
-
* • NEVER-ADOPTED ORPHAN REAP
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* • NEVER-ADOPTED ORPHAN REAP — GATED ON A GLOBAL-BUSY QUEUE CHECK. A
|
|
58
|
+
* degenerate case (bridge death after release, no enqueue ever arrives)
|
|
59
|
+
* would leave a frozen card + a forever `typing…` loop. But the reap must
|
|
60
|
+
* fire ONLY for that genuine orphan, never for a handback that is correctly
|
|
61
|
+
* enqueued and simply waiting behind a long in-flight turn: claude has ONE
|
|
62
|
+
* bridge / ONE input queue, so a released handback mints its adopting turn
|
|
63
|
+
* only when claude next goes idle. A flat age-based reap cannot tell the two
|
|
64
|
+
* apart and (before this gate) posted a false "never started — needs a
|
|
65
|
+
* nudge" card every time a parent turn out-ran the timeout (the 3-cards-in-
|
|
66
|
+
* 4-min incident). The reap therefore consults `isClaudeBusy()` — the
|
|
67
|
+
* machine-level "a turn is in flight right now" signal — and DEFERS
|
|
68
|
+
* (re-arms for another `adoptTimeoutMs`) while claude is busy, finalizing
|
|
69
|
+
* only once claude is idle AND the handback still has not been adopted. That
|
|
70
|
+
* idle-with-no-adoption state is the genuine bridge-death / dropped-inbound
|
|
71
|
+
* degenerate case. Lengthening the flat timeout would merely move the false
|
|
72
|
+
* positive; the queue-state gate removes it. The pre-turn card is persisted
|
|
73
|
+
* as a COMPLETE `ActivityCardRecord`
|
|
60
74
|
* under a SYNTHETIC `turnKey` (`preturn:<statusKey>:<startedAt>`) — synthetic
|
|
61
75
|
* so a sibling REAL turn on the same topic key can neither upsert-clobber it
|
|
62
76
|
* nor keep it "live" (the mid-session reaper's `isLive` keys on live topic
|
|
@@ -171,6 +185,22 @@ export interface HandbackPreturnSignalDeps {
|
|
|
171
185
|
* canonical turn-end owns the stop. Optional; defaults to "no live turn"
|
|
172
186
|
* (the pre-#3544 behaviour). */
|
|
173
187
|
hasLiveTurn?: (statusKey: string) => boolean
|
|
188
|
+
/** True IFF claude is CURRENTLY producing a turn (ANY topic — the machine has
|
|
189
|
+
* ONE bridge / ONE input queue). Consulted by the orphan reap: a released
|
|
190
|
+
* handback is delivered into that single queue and only mints its adopting
|
|
191
|
+
* turn when claude next goes idle, so while claude is busy the handback is
|
|
192
|
+
* NOT orphaned — it is correctly enqueued BEHIND the in-flight turn (a long
|
|
193
|
+
* parent turn can run many minutes). Finalizing it as an orphan in that
|
|
194
|
+
* window posts a false "never started — needs a nudge" card. The reap
|
|
195
|
+
* therefore DEFERS (re-arms) while this is true and finalizes only once
|
|
196
|
+
* claude is idle and the handback still has not been adopted — the genuine
|
|
197
|
+
* bridge-death / dropped-inbound degenerate case the reap exists for.
|
|
198
|
+
* Distinct from `hasLiveTurn`, which is PER-KEY and only governs the
|
|
199
|
+
* typing-loop stop; this is the GLOBAL busy signal because the handback
|
|
200
|
+
* queues behind whatever turn is running, regardless of topic. Optional;
|
|
201
|
+
* defaults to "not busy" (immediate reap — the pre-fix behaviour), so the
|
|
202
|
+
* genuine-orphan path is unchanged. */
|
|
203
|
+
isClaudeBusy?: () => boolean
|
|
174
204
|
now?: () => number
|
|
175
205
|
/** Debounce before painting the pre-turn card (kills sub-second flicker). */
|
|
176
206
|
debounceMs?: number
|
|
@@ -349,6 +379,25 @@ export function createHandbackPreturnSignal(
|
|
|
349
379
|
function reap(entry: PreTurnEntry): void {
|
|
350
380
|
entry.reapTimer = null
|
|
351
381
|
if (entry.consumed) return
|
|
382
|
+
// PRIMARY GATE (queue state, not a flat timeout): a released handback is
|
|
383
|
+
// delivered into claude's SINGLE input queue and mints its adopting turn
|
|
384
|
+
// only when claude next goes idle. If claude is STILL in a turn, this
|
|
385
|
+
// handback is not orphaned — it is correctly enqueued behind the in-flight
|
|
386
|
+
// turn (a long parent turn can run many minutes). Finalizing now would post
|
|
387
|
+
// a false "never started — needs a nudge" card (the 3-cards-in-4-min
|
|
388
|
+
// incident). Defer: re-arm and re-check. We finalize only once claude is
|
|
389
|
+
// idle AND the handback still has not been adopted (the genuine bridge-
|
|
390
|
+
// death / dropped-inbound case). This cannot loop forever on a healthy
|
|
391
|
+
// agent: claude processes its queue FIFO, so the handback is either adopted
|
|
392
|
+
// (cancelling the reap) or claude eventually goes idle (letting it fire).
|
|
393
|
+
if (deps.isClaudeBusy?.() === true) {
|
|
394
|
+
log(
|
|
395
|
+
`handback-preturn-signal: reap deferred key=${entry.statusKey} ` +
|
|
396
|
+
`(claude busy — handback enqueued behind an in-flight turn, not orphaned)\n`,
|
|
397
|
+
)
|
|
398
|
+
entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
352
401
|
entry.consumed = true
|
|
353
402
|
// Stop the forever-running typing loop and finalize the frozen card.
|
|
354
403
|
stopTypingUnlessTurnLive(entry, 'orphan reap')
|
|
@@ -300,6 +300,73 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
300
300
|
expect(h.signal.pendingCount()).toBe(0) // map empty
|
|
301
301
|
})
|
|
302
302
|
|
|
303
|
+
// ── queued-behind-a-long-turn is NOT an orphan (the 3-cards-in-4-min bug) ──
|
|
304
|
+
// A handback released while the parent is mid-turn on a long task is delivered
|
|
305
|
+
// into claude's single input queue and mints its adopting turn only when the
|
|
306
|
+
// parent turn ends. The flat 30 s reap could not tell "enqueued and waiting"
|
|
307
|
+
// from "bridge died, never enqueued" and finalized a false "handback never
|
|
308
|
+
// started — needs a nudge" card. With the queue-state gate (`isClaudeBusy`)
|
|
309
|
+
// the reap DEFERS while claude is busy and the late adoption finalizes the
|
|
310
|
+
// card honestly. This test FAILS on the pre-fix flat-30 s behaviour: without
|
|
311
|
+
// the gate the reap fires at 30 s, finalizes the orphan text, and consumes the
|
|
312
|
+
// entry so the later adoption returns null.
|
|
313
|
+
it('does NOT finalize a handback whose turn adopts late because the parent was busy', async () => {
|
|
314
|
+
let busy = false
|
|
315
|
+
const h = makeHarness({ isClaudeBusy: () => busy })
|
|
316
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatBusy', messageId: 700 }))
|
|
317
|
+
await h.sched.advance(700) // debounce: card painted, reap armed
|
|
318
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
319
|
+
expect(h.records.size).toBe(1)
|
|
320
|
+
|
|
321
|
+
// The parent turn is now in flight (a long Wave-2 assembly). The handback is
|
|
322
|
+
// enqueued behind it.
|
|
323
|
+
busy = true
|
|
324
|
+
|
|
325
|
+
// Two full reap intervals elapse — well beyond the flat 30 s. The reap must
|
|
326
|
+
// DEFER each time: no false orphan card, entry still live, typing still lit.
|
|
327
|
+
await h.sched.advance(30_000)
|
|
328
|
+
await h.sched.advance(30_000)
|
|
329
|
+
expect(h.finalizedIds).toEqual([]) // NEVER posted the "never started" nudge
|
|
330
|
+
expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
|
|
331
|
+
expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
|
|
332
|
+
|
|
333
|
+
// The parent turn finally ends and the handback mints its own turn: it
|
|
334
|
+
// adopts the SAME card (an edit, not a second send / not an orphan finalize).
|
|
335
|
+
busy = false
|
|
336
|
+
const turnId = deriveTurnId('chatBusy', null, 700)!
|
|
337
|
+
const adoption = h.signal.tryAdopt(turnId)
|
|
338
|
+
expect(adoption).not.toBeNull()
|
|
339
|
+
expect(adoption!.activityMessageId).toBe(resolvedId)
|
|
340
|
+
expect(h.finalizedIds).toEqual([]) // adopted, never finalized as orphan
|
|
341
|
+
expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
|
|
342
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// The deferred reap is not a leak: once claude goes idle and the handback has
|
|
346
|
+
// STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
|
|
347
|
+
// reap finalizes honestly — the degenerate case the reap exists for.
|
|
348
|
+
it('finalizes a genuine orphan once claude goes idle, even after deferring while busy', async () => {
|
|
349
|
+
let busy = true
|
|
350
|
+
const h = makeHarness({ isClaudeBusy: () => busy })
|
|
351
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
|
|
352
|
+
await h.sched.advance(700)
|
|
353
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
354
|
+
|
|
355
|
+
// Busy: the first reap defers.
|
|
356
|
+
await h.sched.advance(30_000)
|
|
357
|
+
expect(h.finalizedIds).toEqual([])
|
|
358
|
+
expect(h.signal.pendingCount()).toBe(1)
|
|
359
|
+
|
|
360
|
+
// Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
|
|
361
|
+
// The re-armed reap now finalizes the genuine orphan.
|
|
362
|
+
busy = false
|
|
363
|
+
await h.sched.advance(30_000)
|
|
364
|
+
expect(h.finalizedIds).toEqual([resolvedId])
|
|
365
|
+
expect(h.typing.activeCount()).toBe(0)
|
|
366
|
+
expect(h.records.size).toBe(0)
|
|
367
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
368
|
+
})
|
|
369
|
+
|
|
303
370
|
it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
|
|
304
371
|
const h = makeHarness()
|
|
305
372
|
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))
|