switchroom 0.12.19 → 0.12.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +5 -1
- package/dist/auth-broker/index.js +5 -1
- package/dist/cli/switchroom.js +927 -639
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +11 -7
- package/package.json +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +43 -15
- package/telegram-plugin/gateway/gateway.ts +50 -5
- package/telegram-plugin/gateway/turn-state-purge.ts +71 -0
- package/telegram-plugin/tests/turn-state-purge.test.ts +109 -0
|
@@ -14026,7 +14026,11 @@ var VaultConfigSchema = exports_external.object({
|
|
|
14026
14026
|
path: ["approvalAuth"]
|
|
14027
14027
|
});
|
|
14028
14028
|
}
|
|
14029
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
14029
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
14030
|
+
backup: exports_external.object({
|
|
14031
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
14032
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
14033
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
14030
14034
|
});
|
|
14031
14035
|
var QuotaConfigSchema = exports_external.object({
|
|
14032
14036
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -11285,7 +11285,11 @@ var init_schema = __esm(() => {
|
|
|
11285
11285
|
path: ["approvalAuth"]
|
|
11286
11286
|
});
|
|
11287
11287
|
}
|
|
11288
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
11288
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
11289
|
+
backup: exports_external.object({
|
|
11290
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
11291
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
11292
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
11289
11293
|
});
|
|
11290
11294
|
QuotaConfigSchema = exports_external.object({
|
|
11291
11295
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -11285,7 +11285,11 @@ var init_schema = __esm(() => {
|
|
|
11285
11285
|
path: ["approvalAuth"]
|
|
11286
11286
|
});
|
|
11287
11287
|
}
|
|
11288
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
11288
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
11289
|
+
backup: exports_external.object({
|
|
11290
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
11291
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
11292
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
11289
11293
|
});
|
|
11290
11294
|
QuotaConfigSchema = exports_external.object({
|
|
11291
11295
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -16031,7 +16035,7 @@ class VaultBroker {
|
|
|
16031
16035
|
chownSync(abs, operatorUid, operatorUid);
|
|
16032
16036
|
} catch {}
|
|
16033
16037
|
const unlockServer = net.createServer((sock) => {
|
|
16034
|
-
this._handleUnlockConnection(sock);
|
|
16038
|
+
this._handleUnlockConnection(sock, true);
|
|
16035
16039
|
});
|
|
16036
16040
|
unlockServer.on("error", (err) => rejectP(err));
|
|
16037
16041
|
unlockServer.listen(unlockAbs, () => {
|
|
@@ -17031,9 +17035,9 @@ class VaultBroker {
|
|
|
17031
17035
|
socket.write(encodeResponse(errorResponse("INTERNAL", msg)));
|
|
17032
17036
|
}
|
|
17033
17037
|
}
|
|
17034
|
-
_handleUnlockConnection(socket) {
|
|
17038
|
+
_handleUnlockConnection(socket, isOperator = false) {
|
|
17035
17039
|
let unlockPeer = null;
|
|
17036
|
-
if (process.platform === "linux") {
|
|
17040
|
+
if (!isOperator && process.platform === "linux") {
|
|
17037
17041
|
unlockPeer = this.testOpts._testIdentify ? this.testOpts._testIdentify(this.unlockSocketPath, socket) : identify(this.unlockSocketPath, socket);
|
|
17038
17042
|
if (unlockPeer === null) {
|
|
17039
17043
|
this.auditLogger.write({
|
|
@@ -17048,9 +17052,9 @@ class VaultBroker {
|
|
|
17048
17052
|
return;
|
|
17049
17053
|
}
|
|
17050
17054
|
}
|
|
17051
|
-
const auditPid = unlockPeer?.pid ?? process.pid;
|
|
17052
|
-
const auditCaller = unlockPeer !== null ? callerFromPeer(unlockPeer) : `pid:${process.pid}`;
|
|
17053
|
-
const auditCgroup = unlockPeer?.systemdUnit ?? undefined;
|
|
17055
|
+
const auditPid = isOperator ? process.pid : unlockPeer?.pid ?? process.pid;
|
|
17056
|
+
const auditCaller = isOperator ? "operator" : unlockPeer !== null ? callerFromPeer(unlockPeer) : `pid:${process.pid}`;
|
|
17057
|
+
const auditCgroup = isOperator ? undefined : unlockPeer?.systemdUnit ?? undefined;
|
|
17054
17058
|
let buffer = "";
|
|
17055
17059
|
socket.on("data", (chunk) => {
|
|
17056
17060
|
buffer += chunk.toString("utf8");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.20",
|
|
4
4
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"test:vitest": "vitest run",
|
|
27
27
|
"test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
|
|
28
28
|
"test:watch": "vitest",
|
|
29
|
-
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs",
|
|
29
|
+
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs",
|
|
30
30
|
"lint:tsc": "tsc --noEmit",
|
|
31
31
|
"lint:plugin-references": "node scripts/check-plugin-references.mjs",
|
|
32
32
|
"lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
|
|
@@ -23929,7 +23929,11 @@ var init_schema = __esm(() => {
|
|
|
23929
23929
|
path: ["approvalAuth"]
|
|
23930
23930
|
});
|
|
23931
23931
|
}
|
|
23932
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
23932
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
23933
|
+
backup: exports_external.object({
|
|
23934
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
23935
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
23936
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional \u2014 the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located \u2014 `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
23933
23937
|
});
|
|
23934
23938
|
QuotaConfigSchema = exports_external.object({
|
|
23935
23939
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -43725,6 +43729,27 @@ function createInboundSpool(opts) {
|
|
|
43725
43729
|
};
|
|
43726
43730
|
}
|
|
43727
43731
|
|
|
43732
|
+
// gateway/turn-state-purge.ts
|
|
43733
|
+
function purgeStaleTurnsForChat(chatId, keys, purger) {
|
|
43734
|
+
if (!chatId)
|
|
43735
|
+
return { purged: [] };
|
|
43736
|
+
const purged = [];
|
|
43737
|
+
const snapshot = [];
|
|
43738
|
+
for (const k of keys)
|
|
43739
|
+
snapshot.push(k);
|
|
43740
|
+
for (const key of snapshot) {
|
|
43741
|
+
const sep3 = key.indexOf(":");
|
|
43742
|
+
if (sep3 < 0)
|
|
43743
|
+
continue;
|
|
43744
|
+
const keyChat = key.slice(0, sep3);
|
|
43745
|
+
if (keyChat !== chatId)
|
|
43746
|
+
continue;
|
|
43747
|
+
purger(key);
|
|
43748
|
+
purged.push(key);
|
|
43749
|
+
}
|
|
43750
|
+
return { purged };
|
|
43751
|
+
}
|
|
43752
|
+
|
|
43728
43753
|
// gateway/inbound-delivery-gate.ts
|
|
43729
43754
|
function decideInboundDelivery(input) {
|
|
43730
43755
|
if (input.turnInFlight && !input.isSteering)
|
|
@@ -47090,11 +47115,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47090
47115
|
}
|
|
47091
47116
|
|
|
47092
47117
|
// ../src/build-info.ts
|
|
47093
|
-
var VERSION = "0.12.
|
|
47094
|
-
var COMMIT_SHA = "
|
|
47095
|
-
var COMMIT_DATE = "2026-05-
|
|
47096
|
-
var LATEST_PR =
|
|
47097
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
47118
|
+
var VERSION = "0.12.20";
|
|
47119
|
+
var COMMIT_SHA = "5191cef3";
|
|
47120
|
+
var COMMIT_DATE = "2026-05-20T00:03:17Z";
|
|
47121
|
+
var LATEST_PR = 1565;
|
|
47122
|
+
var COMMITS_AHEAD_OF_TAG = 6;
|
|
47098
47123
|
|
|
47099
47124
|
// gateway/boot-version.ts
|
|
47100
47125
|
function formatRelativeAgo(iso) {
|
|
@@ -48071,6 +48096,12 @@ function purgeReactionTracking(key) {
|
|
|
48071
48096
|
}
|
|
48072
48097
|
}
|
|
48073
48098
|
}
|
|
48099
|
+
function endCurrentTurnAtomic(turn) {
|
|
48100
|
+
if (currentTurn !== turn)
|
|
48101
|
+
return;
|
|
48102
|
+
currentTurn = null;
|
|
48103
|
+
purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId));
|
|
48104
|
+
}
|
|
48074
48105
|
function maybeProactiveCompact() {
|
|
48075
48106
|
if (compactDispatching)
|
|
48076
48107
|
return;
|
|
@@ -48912,6 +48943,7 @@ startTimer({
|
|
|
48912
48943
|
preambleSuppressor.dropNow();
|
|
48913
48944
|
endTurn(fbKey);
|
|
48914
48945
|
purgeReactionTracking(fbKey);
|
|
48946
|
+
const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
|
|
48915
48947
|
if (turnMatchesFallback && currentTurn === wedgedTurn)
|
|
48916
48948
|
currentTurn = null;
|
|
48917
48949
|
try {
|
|
@@ -48919,7 +48951,7 @@ startTimer({
|
|
|
48919
48951
|
} catch {}
|
|
48920
48952
|
const fbSelfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
48921
48953
|
const fbRedeliver = redeliverBufferedInbound(pendingInboundBuffer, fbSelfAgent, (m) => ipcServer.sendToAgent(fbSelfAgent, m), inboundSpool);
|
|
48922
|
-
process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}
|
|
48954
|
+
process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ""}
|
|
48923
48955
|
`);
|
|
48924
48956
|
}
|
|
48925
48957
|
});
|
|
@@ -50709,8 +50741,7 @@ function handleSessionEvent(ev) {
|
|
|
50709
50741
|
turn.answerStream.stop();
|
|
50710
50742
|
turn.answerStream = null;
|
|
50711
50743
|
}
|
|
50712
|
-
|
|
50713
|
-
currentTurn = null;
|
|
50744
|
+
endCurrentTurnAtomic(turn);
|
|
50714
50745
|
preambleSuppressor.reset();
|
|
50715
50746
|
}
|
|
50716
50747
|
return;
|
|
@@ -50827,8 +50858,7 @@ function handleSessionEvent(ev) {
|
|
|
50827
50858
|
lastPtyPreviewByChat.delete(statusKey(chatId, threadId));
|
|
50828
50859
|
pendingPtyPartial = null;
|
|
50829
50860
|
closeActivityLane(chatId, threadId);
|
|
50830
|
-
|
|
50831
|
-
currentTurn = null;
|
|
50861
|
+
endCurrentTurnAtomic(turn);
|
|
50832
50862
|
preambleSuppressor.dropNow();
|
|
50833
50863
|
return;
|
|
50834
50864
|
}
|
|
@@ -50843,8 +50873,7 @@ function handleSessionEvent(ev) {
|
|
|
50843
50873
|
}) ?? { wasEmitted: false, turnKey: null };
|
|
50844
50874
|
const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
|
|
50845
50875
|
const backstopCardTurnKey = cardTakeover.turnKey;
|
|
50846
|
-
|
|
50847
|
-
currentTurn = null;
|
|
50876
|
+
endCurrentTurnAtomic(turn);
|
|
50848
50877
|
preambleSuppressor.dropNow();
|
|
50849
50878
|
{
|
|
50850
50879
|
const tKey = statusKey(chatId, threadId);
|
|
@@ -51009,8 +51038,7 @@ function handleSessionEvent(ev) {
|
|
|
51009
51038
|
}
|
|
51010
51039
|
}
|
|
51011
51040
|
removeTurnActiveMarker(STATE_DIR);
|
|
51012
|
-
|
|
51013
|
-
currentTurn = null;
|
|
51041
|
+
endCurrentTurnAtomic(turn);
|
|
51014
51042
|
return;
|
|
51015
51043
|
}
|
|
51016
51044
|
}
|
|
@@ -250,6 +250,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
250
250
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
251
251
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
252
252
|
import { createInboundSpool } from './inbound-spool.js'
|
|
253
|
+
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
253
254
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
254
255
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
255
256
|
import {
|
|
@@ -1318,6 +1319,32 @@ function purgeReactionTracking(key: string): void {
|
|
|
1318
1319
|
}
|
|
1319
1320
|
}
|
|
1320
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Atomic null-and-purge for a wedged turn. Every site that ends a
|
|
1324
|
+
* turn by nulling `currentTurn` MUST also clear the turn's statusKey
|
|
1325
|
+
* from `activeTurnStartedAt` — else a dangling entry survives and
|
|
1326
|
+
* `#1556`'s turn-gate holds every new inbound mid-turn forever
|
|
1327
|
+
* (gymbro / klanker held-mid-turn symptom, 2026-05-20).
|
|
1328
|
+
*
|
|
1329
|
+
* Pre-this, three turn-end paths (silent-marker / turn-flush /
|
|
1330
|
+
* `turn_end`) nulled `currentTurn` on code-paths whose
|
|
1331
|
+
* `purgeReactionTracking` calls weren't reached on every branch,
|
|
1332
|
+
* leaving sibling entries under the turn's statusKey that the
|
|
1333
|
+
* silence-poke framework-fallback's `purgeReactionTracking(fbKey)`
|
|
1334
|
+
* couldn't catch (different key shape). The fallback now also sweeps
|
|
1335
|
+
* siblings for `fbChatId` (`turn-state-purge.ts`) as defense-in-depth,
|
|
1336
|
+
* but THIS helper closes the leak at origin: null and purge are
|
|
1337
|
+
* inseparable at every call site.
|
|
1338
|
+
*
|
|
1339
|
+
* Idempotent: a second purge is a no-op `.delete()` on a key already
|
|
1340
|
+
* gone — handlers that already purge elsewhere are unharmed.
|
|
1341
|
+
*/
|
|
1342
|
+
function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
1343
|
+
if (currentTurn !== turn) return
|
|
1344
|
+
currentTurn = null
|
|
1345
|
+
purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1321
1348
|
/**
|
|
1322
1349
|
* Model-idle proactive-compaction check. Called ONLY from the
|
|
1323
1350
|
* activeTurnStartedAt.size === 0 gate above (never mid-turn). Opt-in via
|
|
@@ -3011,6 +3038,23 @@ silencePoke.startTimer({
|
|
|
3011
3038
|
// for this chat starts a fresh turn instead of queueing forever.
|
|
3012
3039
|
silencePoke.endTurn(fbKey)
|
|
3013
3040
|
purgeReactionTracking(fbKey)
|
|
3041
|
+
// Defense-in-depth: the fallback's purgeReactionTracking above
|
|
3042
|
+
// clears the canonical statusKey(chatId, threadId) for fbKey
|
|
3043
|
+
// only. activeTurnStartedAt can hold sibling entries for the
|
|
3044
|
+
// SAME chat (different threads, or a `null` vs `undefined`-thread
|
|
3045
|
+
// variant left over from a normal turn-end path that nulled
|
|
3046
|
+
// currentTurn without invoking purgeReactionTracking — the
|
|
3047
|
+
// gymbro/klanker held-mid-turn symptom, 2026-05-20). Any sibling
|
|
3048
|
+
// for fbChatId is by definition stale when THIS fallback fires
|
|
3049
|
+
// (the chat has been silent ≥5 min); sweep them via the same
|
|
3050
|
+
// purger. Multi-chat-safe — only touches keys for fbChatId, so
|
|
3051
|
+
// #1546's intentional cross-chat safety guard is preserved.
|
|
3052
|
+
// See turn-state-purge.ts.
|
|
3053
|
+
const fbExtraPurge = purgeStaleTurnsForChat(
|
|
3054
|
+
fbChatId,
|
|
3055
|
+
activeTurnStartedAt.keys(),
|
|
3056
|
+
purgeReactionTracking,
|
|
3057
|
+
)
|
|
3014
3058
|
// Null `currentTurn` if it's still pointing at the wedged turn —
|
|
3015
3059
|
// when claude eventually fires a late `turn_end` for this session
|
|
3016
3060
|
// (or never does), the handler's `const turn = currentTurn` snapshot
|
|
@@ -3044,7 +3088,8 @@ silencePoke.startTimer({
|
|
|
3044
3088
|
`chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
|
|
3045
3089
|
`currentTurn_nulled=${turnMatchesFallback} ` +
|
|
3046
3090
|
`drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
|
|
3047
|
-
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}
|
|
3091
|
+
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
|
|
3092
|
+
`${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
|
|
3048
3093
|
)
|
|
3049
3094
|
},
|
|
3050
3095
|
})
|
|
@@ -5686,7 +5731,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5686
5731
|
turn.answerStream = null
|
|
5687
5732
|
}
|
|
5688
5733
|
// Null the atom — this turn is being abandoned.
|
|
5689
|
-
|
|
5734
|
+
endCurrentTurnAtomic(turn)
|
|
5690
5735
|
// #549 fix — context-exhaustion teardown also resets preamble state.
|
|
5691
5736
|
preambleSuppressor.reset()
|
|
5692
5737
|
}
|
|
@@ -5884,7 +5929,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5884
5929
|
// returns early at handler entry. A new `enqueue` swaps in a
|
|
5885
5930
|
// fresh atom; the silent-turn teardown doesn't need to preserve
|
|
5886
5931
|
// any of the prior turn's state.
|
|
5887
|
-
|
|
5932
|
+
endCurrentTurnAtomic(turn)
|
|
5888
5933
|
// #549 fix — silent-marker teardown drops any pending preamble.
|
|
5889
5934
|
preambleSuppressor.dropNow()
|
|
5890
5935
|
return
|
|
@@ -5918,7 +5963,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5918
5963
|
// sendMessage await for this turn will see currentTurn == null
|
|
5919
5964
|
// and bail; a new enqueue will swap in a fresh atom. The
|
|
5920
5965
|
// `backstop*` locals above hold everything the IIFE needs.
|
|
5921
|
-
|
|
5966
|
+
endCurrentTurnAtomic(turn)
|
|
5922
5967
|
// #549 fix — turn-flush takes ownership of the captured-text
|
|
5923
5968
|
// backup; reset the preamble buffer (its content is already in
|
|
5924
5969
|
// the captured `capturedText`, which turn-flush is about to send).
|
|
@@ -6190,7 +6235,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6190
6235
|
// #1067: null the atom in one assignment, replacing the seven
|
|
6191
6236
|
// field clears the pre-refactor version did. Any late-arriving
|
|
6192
6237
|
// event for this turn will see currentTurn == null and bail.
|
|
6193
|
-
|
|
6238
|
+
endCurrentTurnAtomic(turn)
|
|
6194
6239
|
// #549 fix — preamble flush already happened at the TOP of this
|
|
6195
6240
|
// turn_end handler (before turn.answerStream is nulled). See
|
|
6196
6241
|
// comment near line 3431.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-state-purge.ts — defense-in-depth cleanup for the silence-poke
|
|
3
|
+
* framework-fallback (gateway.ts).
|
|
4
|
+
*
|
|
5
|
+
* The fallback's `purgeReactionTracking(fbKey)` clears the canonical
|
|
6
|
+
* `statusKey(chatId, threadId)` for the exact chat+thread that armed
|
|
7
|
+
* the silence-poke. But `activeTurnStartedAt` (and its siblings) can
|
|
8
|
+
* legitimately hold MORE than one entry for the same chat — e.g.:
|
|
9
|
+
*
|
|
10
|
+
* - a normal turn-end handler null'd `currentTurn` but skipped
|
|
11
|
+
* `purgeReactionTracking` on a code-path that didn't reach it
|
|
12
|
+
* (gateway.ts:5887/5921/6193 — purgeReactionTracking calls in
|
|
13
|
+
* those handlers are conditional/not on every branch), so
|
|
14
|
+
* `activeTurnStartedAt[oldKey]` is dangling when the fallback
|
|
15
|
+
* fires for a NEW key
|
|
16
|
+
* - a multi-thread chat (topic-based group, or a `null` vs
|
|
17
|
+
* `undefined` thread variant) has entries under different keys of
|
|
18
|
+
* the SAME chat
|
|
19
|
+
*
|
|
20
|
+
* In either case, the fallback purges `fbKey` but a sibling key for
|
|
21
|
+
* the same chat remains → `activeTurnStartedAt.size > 0` persists →
|
|
22
|
+
* `#1556`'s turn-gate holds every new inbound forever → user sees
|
|
23
|
+
* "not responding" while claude is actually idle (gymbro + klanker,
|
|
24
|
+
* 2026-05-20).
|
|
25
|
+
*
|
|
26
|
+
* This helper sweeps any sibling keys for the firing chat after the
|
|
27
|
+
* canonical `purgeReactionTracking(fbKey)`, via the same purger.
|
|
28
|
+
* Multi-chat safe: it only touches keys whose chatId matches — other
|
|
29
|
+
* chats' active turns are untouched, preserving the deliberate
|
|
30
|
+
* multi-chat safety #1546 kept.
|
|
31
|
+
*
|
|
32
|
+
* Key shape: `statusKey(chatId, threadId)` emits
|
|
33
|
+
* `${chatId}:${threadId ?? '_'}`. Chat ids are numeric strings (no
|
|
34
|
+
* `:` inside), so `indexOf(':')` is a safe prefix delimiter. A
|
|
35
|
+
* "prefix-without-separator" false positive (e.g. chatId `123` vs key
|
|
36
|
+
* `1234:_`) is structurally impossible because the helper splits on
|
|
37
|
+
* `:` and equates the prefix, NOT a string-startsWith.
|
|
38
|
+
*
|
|
39
|
+
* Pure / dependency-free so the multi-chat-safety and key-shape logic
|
|
40
|
+
* are unit-testable without standing up a gateway — mirrors the
|
|
41
|
+
* `#1544 / #1546 / #1549 / #1558` pure-seam idiom.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface PurgeStaleTurnsResult {
|
|
45
|
+
/** Keys for `chatId` that the helper purged via the callback. */
|
|
46
|
+
purged: string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function purgeStaleTurnsForChat(
|
|
50
|
+
chatId: string,
|
|
51
|
+
keys: Iterable<string>,
|
|
52
|
+
purger: (key: string) => void,
|
|
53
|
+
): PurgeStaleTurnsResult {
|
|
54
|
+
if (!chatId) return { purged: [] }
|
|
55
|
+
const purged: string[] = []
|
|
56
|
+
// Snapshot first — `purger` typically deletes from the same Map the
|
|
57
|
+
// caller is iterating; mutating during iteration is correct on JS
|
|
58
|
+
// Maps but a snapshot makes the contract explicit and lets the
|
|
59
|
+
// helper accept any iterable.
|
|
60
|
+
const snapshot: string[] = []
|
|
61
|
+
for (const k of keys) snapshot.push(k)
|
|
62
|
+
for (const key of snapshot) {
|
|
63
|
+
const sep = key.indexOf(':')
|
|
64
|
+
if (sep < 0) continue // malformed / non-statusKey shape — skip
|
|
65
|
+
const keyChat = key.slice(0, sep)
|
|
66
|
+
if (keyChat !== chatId) continue
|
|
67
|
+
purger(key)
|
|
68
|
+
purged.push(key)
|
|
69
|
+
}
|
|
70
|
+
return { purged }
|
|
71
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn-state-purge — the silence-poke fallback's defense-in-depth
|
|
3
|
+
* cleanup. Pins the multi-chat safety + key-shape correctness that
|
|
4
|
+
* the gymbro/klanker held-mid-turn fix (2026-05-20) depends on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import { purgeStaleTurnsForChat } from '../gateway/turn-state-purge.js'
|
|
9
|
+
|
|
10
|
+
function statusKey(chatId: string, threadId?: number): string {
|
|
11
|
+
return `${chatId}:${threadId ?? '_'}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('purgeStaleTurnsForChat', () => {
|
|
15
|
+
it('returns [] and calls nothing when keys is empty', () => {
|
|
16
|
+
let calls = 0
|
|
17
|
+
const r = purgeStaleTurnsForChat('123', [], () => calls++)
|
|
18
|
+
expect(r.purged).toEqual([])
|
|
19
|
+
expect(calls).toBe(0)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns [] and calls nothing when chatId is empty (guard)', () => {
|
|
23
|
+
let calls = 0
|
|
24
|
+
const r = purgeStaleTurnsForChat('', [statusKey('123')], () => calls++)
|
|
25
|
+
expect(r.purged).toEqual([])
|
|
26
|
+
expect(calls).toBe(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('purges the canonical DM key for the firing chat (typical case)', () => {
|
|
30
|
+
const purged: string[] = []
|
|
31
|
+
const r = purgeStaleTurnsForChat(
|
|
32
|
+
'12345',
|
|
33
|
+
[statusKey('12345')],
|
|
34
|
+
(k) => purged.push(k),
|
|
35
|
+
)
|
|
36
|
+
expect(r.purged).toEqual(['12345:_'])
|
|
37
|
+
expect(purged).toEqual(['12345:_'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('purges every sibling key for the same chat (multi-thread / dangling)', () => {
|
|
41
|
+
// This is the gymbro/klanker symptom: a stale activeTurnStartedAt
|
|
42
|
+
// entry under a different thread of the same chat survives the
|
|
43
|
+
// canonical purgeReactionTracking(fbKey).
|
|
44
|
+
const purged: string[] = []
|
|
45
|
+
const r = purgeStaleTurnsForChat(
|
|
46
|
+
'12345',
|
|
47
|
+
[
|
|
48
|
+
statusKey('12345'), // DM, no thread
|
|
49
|
+
statusKey('12345', 42), // thread 42
|
|
50
|
+
statusKey('12345', 99), // thread 99
|
|
51
|
+
],
|
|
52
|
+
(k) => purged.push(k),
|
|
53
|
+
)
|
|
54
|
+
expect(r.purged.sort()).toEqual(['12345:42', '12345:99', '12345:_'])
|
|
55
|
+
expect(purged).toHaveLength(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('NEVER touches keys for OTHER chats (multi-chat safety — the #1546 guard)', () => {
|
|
59
|
+
const purged: string[] = []
|
|
60
|
+
const r = purgeStaleTurnsForChat(
|
|
61
|
+
'12345',
|
|
62
|
+
[
|
|
63
|
+
statusKey('12345'), // target chat — purge
|
|
64
|
+
statusKey('-1001234567890'), // different chat — must NOT touch
|
|
65
|
+
statusKey('-1001234567890', 7), // different chat thread — must NOT touch
|
|
66
|
+
],
|
|
67
|
+
(k) => purged.push(k),
|
|
68
|
+
)
|
|
69
|
+
expect(r.purged).toEqual(['12345:_'])
|
|
70
|
+
expect(purged).toEqual(['12345:_']) // ONLY the target chat
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not false-match on a chatId-prefix superstring (e.g. 123 vs 1234)', () => {
|
|
74
|
+
// If we used a naive startsWith on the WHOLE key, chatId "123"
|
|
75
|
+
// would falsely match key "1234:_". The helper splits on `:` and
|
|
76
|
+
// equates the chatId slice, which prevents that.
|
|
77
|
+
const purged: string[] = []
|
|
78
|
+
const r = purgeStaleTurnsForChat(
|
|
79
|
+
'123',
|
|
80
|
+
[statusKey('123'), statusKey('1234'), statusKey('12')],
|
|
81
|
+
(k) => purged.push(k),
|
|
82
|
+
)
|
|
83
|
+
expect(r.purged).toEqual(['123:_']) // ONLY the exact match
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('skips malformed keys (no `:`)', () => {
|
|
87
|
+
let calls = 0
|
|
88
|
+
const r = purgeStaleTurnsForChat(
|
|
89
|
+
'123',
|
|
90
|
+
['malformed', '', 'no-colon-here', statusKey('123')],
|
|
91
|
+
() => calls++,
|
|
92
|
+
)
|
|
93
|
+
expect(r.purged).toEqual(['123:_'])
|
|
94
|
+
expect(calls).toBe(1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('accepts any iterable (snapshots before iteration — purger may delete)', () => {
|
|
98
|
+
// The real call site iterates `activeTurnStartedAt.keys()` and
|
|
99
|
+
// the purger deletes from that same Map. Snapshotting in the
|
|
100
|
+
// helper prevents iterator skew.
|
|
101
|
+
const map = new Map<string, number>()
|
|
102
|
+
map.set('123:_', 1)
|
|
103
|
+
map.set('123:7', 2)
|
|
104
|
+
map.set('999:_', 3)
|
|
105
|
+
const r = purgeStaleTurnsForChat('123', map.keys(), (k) => map.delete(k))
|
|
106
|
+
expect(r.purged.sort()).toEqual(['123:7', '123:_'])
|
|
107
|
+
expect([...map.keys()]).toEqual(['999:_']) // multi-chat safety preserved
|
|
108
|
+
})
|
|
109
|
+
})
|