switchroom 0.14.39 → 0.14.40
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/auth-broker/index.js +294 -46
- package/dist/cli/drive-write-pretool.mjs +25 -1
- package/dist/cli/switchroom.js +63 -6
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +103 -11
- package/telegram-plugin/gateway/gateway.ts +81 -3
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +96 -0
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/inbound-no-drop-rapid-fire-dm.test.ts +64 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -25257,7 +25257,7 @@ function decodeResponse2(line) {
|
|
|
25257
25257
|
}
|
|
25258
25258
|
return ResponseSchema2.parse(parsed);
|
|
25259
25259
|
}
|
|
25260
|
-
var MAX_FRAME_BYTES2, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema2, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema2, ResponseSchema2;
|
|
25260
|
+
var MAX_FRAME_BYTES2, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema2, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema2, ResponseSchema2;
|
|
25261
25261
|
var init_protocol2 = __esm(() => {
|
|
25262
25262
|
init_zod();
|
|
25263
25263
|
MAX_FRAME_BYTES2 = 64 * 1024;
|
|
@@ -25361,6 +25361,11 @@ var init_protocol2 = __esm(() => {
|
|
|
25361
25361
|
op: exports_external.literal("list-google-accounts"),
|
|
25362
25362
|
id: exports_external.string().min(1)
|
|
25363
25363
|
});
|
|
25364
|
+
ListMicrosoftAccountsRequestSchema = exports_external.object({
|
|
25365
|
+
v: exports_external.literal(PROTOCOL_VERSION),
|
|
25366
|
+
op: exports_external.literal("list-microsoft-accounts"),
|
|
25367
|
+
id: exports_external.string().min(1)
|
|
25368
|
+
});
|
|
25364
25369
|
ProbeQuotaRequestSchema = exports_external.object({
|
|
25365
25370
|
v: exports_external.literal(PROTOCOL_VERSION),
|
|
25366
25371
|
op: exports_external.literal("probe-quota"),
|
|
@@ -25378,6 +25383,7 @@ var init_protocol2 = __esm(() => {
|
|
|
25378
25383
|
RmAccountRequestSchema,
|
|
25379
25384
|
SetOverrideRequestSchema,
|
|
25380
25385
|
ListGoogleAccountsRequestSchema,
|
|
25386
|
+
ListMicrosoftAccountsRequestSchema,
|
|
25381
25387
|
ProbeQuotaRequestSchema
|
|
25382
25388
|
]);
|
|
25383
25389
|
GetCredentialsDataSchema = exports_external.object({
|
|
@@ -25442,6 +25448,16 @@ var init_protocol2 = __esm(() => {
|
|
|
25442
25448
|
ListGoogleAccountsDataSchema = exports_external.object({
|
|
25443
25449
|
accounts: exports_external.array(GoogleAccountStateSchema)
|
|
25444
25450
|
});
|
|
25451
|
+
MicrosoftAccountStateSchema = exports_external.object({
|
|
25452
|
+
account: exports_external.string(),
|
|
25453
|
+
expiresAt: exports_external.number(),
|
|
25454
|
+
scope: exports_external.string(),
|
|
25455
|
+
clientId: exports_external.string(),
|
|
25456
|
+
accountType: exports_external.enum(["personal", "work"])
|
|
25457
|
+
});
|
|
25458
|
+
ListMicrosoftAccountsDataSchema = exports_external.object({
|
|
25459
|
+
accounts: exports_external.array(MicrosoftAccountStateSchema)
|
|
25460
|
+
});
|
|
25445
25461
|
ErrorBodySchema = exports_external.object({
|
|
25446
25462
|
code: exports_external.enum([
|
|
25447
25463
|
"FORBIDDEN",
|
|
@@ -25565,6 +25581,14 @@ class AuthBrokerClient {
|
|
|
25565
25581
|
});
|
|
25566
25582
|
return data;
|
|
25567
25583
|
}
|
|
25584
|
+
async listMicrosoftAccounts() {
|
|
25585
|
+
const data = await this.send({
|
|
25586
|
+
v: PROTOCOL_VERSION,
|
|
25587
|
+
id: randomUUID(),
|
|
25588
|
+
op: "list-microsoft-accounts"
|
|
25589
|
+
});
|
|
25590
|
+
return data;
|
|
25591
|
+
}
|
|
25568
25592
|
async probeQuota(accounts, timeoutMs) {
|
|
25569
25593
|
const data = await this.send({
|
|
25570
25594
|
v: PROTOCOL_VERSION,
|
|
@@ -49438,8 +49462,8 @@ var {
|
|
|
49438
49462
|
} = import__.default;
|
|
49439
49463
|
|
|
49440
49464
|
// src/build-info.ts
|
|
49441
|
-
var VERSION = "0.14.
|
|
49442
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.40";
|
|
49466
|
+
var COMMIT_SHA = "d2d69140";
|
|
49443
49467
|
|
|
49444
49468
|
// src/cli/agent.ts
|
|
49445
49469
|
init_source();
|
|
@@ -57992,13 +58016,46 @@ function registerAccountRemove2(accountParent) {
|
|
|
57992
58016
|
}));
|
|
57993
58017
|
}
|
|
57994
58018
|
function registerAccountList2(accountParent) {
|
|
57995
|
-
accountParent.command("list").description("List Microsoft accounts the broker holds credentials for. Distinct from `auth microsoft list` (YAML ACL matrix).").option("--json", "Emit raw JSON").action(withConfigError(async (
|
|
58019
|
+
accountParent.command("list").description("List Microsoft accounts the broker holds credentials for. Distinct from `auth microsoft list` (YAML ACL matrix).").option("--json", "Emit raw JSON").action(withConfigError(async (opts) => {
|
|
58020
|
+
const { brokerCall: brokerCall2 } = await Promise.resolve().then(() => (init_broker_call(), exports_broker_call));
|
|
58021
|
+
const data = await brokerCall2(async (client) => client.listMicrosoftAccounts());
|
|
58022
|
+
if (opts.json) {
|
|
58023
|
+
console.log(JSON.stringify(data, null, 2));
|
|
58024
|
+
return;
|
|
58025
|
+
}
|
|
57996
58026
|
console.log();
|
|
57997
|
-
|
|
57998
|
-
|
|
58027
|
+
if (data.accounts.length === 0) {
|
|
58028
|
+
console.log(source_default.gray(" No Microsoft accounts stored in broker."));
|
|
58029
|
+
console.log(` Add one: ${source_default.bold("switchroom auth microsoft account add <email>")}`);
|
|
58030
|
+
console.log();
|
|
58031
|
+
return;
|
|
58032
|
+
}
|
|
58033
|
+
const accountColWidth = Math.max(...data.accounts.map((a) => a.account.length), "ACCOUNT".length);
|
|
58034
|
+
const typeColWidth = "TYPE".length + 4;
|
|
58035
|
+
const expiresColWidth = "EXPIRES".length + 2;
|
|
58036
|
+
console.log(`${pad2("ACCOUNT", accountColWidth)} ${pad2("TYPE", typeColWidth)} ${pad2("EXPIRES", expiresColWidth)} SCOPE`);
|
|
58037
|
+
console.log(`${pad2("-".repeat(7), accountColWidth)} ${pad2("-".repeat(4), typeColWidth)} ${pad2("-".repeat(7), expiresColWidth)} ${"-".repeat(5)}`);
|
|
58038
|
+
const now = Date.now();
|
|
58039
|
+
for (const a of data.accounts) {
|
|
58040
|
+
const expiresLabel = formatMicrosoftExpiry(a.expiresAt - now);
|
|
58041
|
+
const scopes = a.scope.split(" ").filter((s) => s.length > 0 && !["openid", "profile", "email", "offline_access"].includes(s)).join(", ");
|
|
58042
|
+
console.log(`${pad2(a.account, accountColWidth)} ${pad2(a.accountType, typeColWidth)} ${pad2(expiresLabel, expiresColWidth)} ${scopes}`);
|
|
58043
|
+
}
|
|
57999
58044
|
console.log();
|
|
58000
58045
|
}));
|
|
58001
58046
|
}
|
|
58047
|
+
function formatMicrosoftExpiry(remainingMs) {
|
|
58048
|
+
if (remainingMs <= 0)
|
|
58049
|
+
return source_default.red("expired");
|
|
58050
|
+
const minutes = Math.round(remainingMs / 60000);
|
|
58051
|
+
if (minutes < 60)
|
|
58052
|
+
return `${minutes}m`;
|
|
58053
|
+
const hours = Math.round(minutes / 60);
|
|
58054
|
+
if (hours < 48)
|
|
58055
|
+
return `${hours}h`;
|
|
58056
|
+
const days = Math.round(hours / 24);
|
|
58057
|
+
return `${days}d`;
|
|
58058
|
+
}
|
|
58002
58059
|
function validateAndNormalizeAccountEmail2(account) {
|
|
58003
58060
|
const normalized = account.trim().toLowerCase();
|
|
58004
58061
|
if (!/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/.test(normalized)) {
|
package/package.json
CHANGED
|
@@ -16160,7 +16160,7 @@ function decodeResponse(line) {
|
|
|
16160
16160
|
}
|
|
16161
16161
|
return ResponseSchema.parse(parsed);
|
|
16162
16162
|
}
|
|
16163
|
-
var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
|
|
16163
|
+
var MAX_FRAME_BYTES, PROTOCOL_VERSION = 1, ProviderNameSchema, GetCredentialsRequestSchema, ListStateRequestSchema, SetActiveRequestSchema, MarkExhaustedRequestSchema, RefreshAccountRequestSchema, AnthropicCredentialsSchema, GoogleCredentialsSchema, MicrosoftCredentialsSchema, ProviderCredentialsSchema, AddAccountRequestSchema, RmAccountRequestSchema, SetOverrideRequestSchema, ListGoogleAccountsRequestSchema, ListMicrosoftAccountsRequestSchema, ProbeQuotaRequestSchema, RequestSchema, GetCredentialsDataSchema, AccountStateSchema, AgentStateSchema, ConsumerStateSchema, ListStateDataSchema, SetActiveDataSchema, MarkExhaustedDataSchema, RefreshAccountDataSchema, AddAccountDataSchema, RmAccountDataSchema, SetOverrideDataSchema, GoogleAccountStateSchema, ListGoogleAccountsDataSchema, MicrosoftAccountStateSchema, ListMicrosoftAccountsDataSchema, ErrorBodySchema, SuccessResponseSchema, ErrorResponseSchema, ResponseSchema;
|
|
16164
16164
|
var init_protocol = __esm(() => {
|
|
16165
16165
|
init_zod();
|
|
16166
16166
|
MAX_FRAME_BYTES = 64 * 1024;
|
|
@@ -16264,6 +16264,11 @@ var init_protocol = __esm(() => {
|
|
|
16264
16264
|
op: exports_external.literal("list-google-accounts"),
|
|
16265
16265
|
id: exports_external.string().min(1)
|
|
16266
16266
|
});
|
|
16267
|
+
ListMicrosoftAccountsRequestSchema = exports_external.object({
|
|
16268
|
+
v: exports_external.literal(PROTOCOL_VERSION),
|
|
16269
|
+
op: exports_external.literal("list-microsoft-accounts"),
|
|
16270
|
+
id: exports_external.string().min(1)
|
|
16271
|
+
});
|
|
16267
16272
|
ProbeQuotaRequestSchema = exports_external.object({
|
|
16268
16273
|
v: exports_external.literal(PROTOCOL_VERSION),
|
|
16269
16274
|
op: exports_external.literal("probe-quota"),
|
|
@@ -16281,6 +16286,7 @@ var init_protocol = __esm(() => {
|
|
|
16281
16286
|
RmAccountRequestSchema,
|
|
16282
16287
|
SetOverrideRequestSchema,
|
|
16283
16288
|
ListGoogleAccountsRequestSchema,
|
|
16289
|
+
ListMicrosoftAccountsRequestSchema,
|
|
16284
16290
|
ProbeQuotaRequestSchema
|
|
16285
16291
|
]);
|
|
16286
16292
|
GetCredentialsDataSchema = exports_external.object({
|
|
@@ -16345,6 +16351,16 @@ var init_protocol = __esm(() => {
|
|
|
16345
16351
|
ListGoogleAccountsDataSchema = exports_external.object({
|
|
16346
16352
|
accounts: exports_external.array(GoogleAccountStateSchema)
|
|
16347
16353
|
});
|
|
16354
|
+
MicrosoftAccountStateSchema = exports_external.object({
|
|
16355
|
+
account: exports_external.string(),
|
|
16356
|
+
expiresAt: exports_external.number(),
|
|
16357
|
+
scope: exports_external.string(),
|
|
16358
|
+
clientId: exports_external.string(),
|
|
16359
|
+
accountType: exports_external.enum(["personal", "work"])
|
|
16360
|
+
});
|
|
16361
|
+
ListMicrosoftAccountsDataSchema = exports_external.object({
|
|
16362
|
+
accounts: exports_external.array(MicrosoftAccountStateSchema)
|
|
16363
|
+
});
|
|
16348
16364
|
ErrorBodySchema = exports_external.object({
|
|
16349
16365
|
code: exports_external.enum([
|
|
16350
16366
|
"FORBIDDEN",
|
|
@@ -16468,6 +16484,14 @@ class AuthBrokerClient {
|
|
|
16468
16484
|
});
|
|
16469
16485
|
return data;
|
|
16470
16486
|
}
|
|
16487
|
+
async listMicrosoftAccounts() {
|
|
16488
|
+
const data = await this.send({
|
|
16489
|
+
v: PROTOCOL_VERSION,
|
|
16490
|
+
id: randomUUID3(),
|
|
16491
|
+
op: "list-microsoft-accounts"
|
|
16492
|
+
});
|
|
16493
|
+
return data;
|
|
16494
|
+
}
|
|
16471
16495
|
async probeQuota(accounts, timeoutMs) {
|
|
16472
16496
|
const data = await this.send({
|
|
16473
16497
|
v: PROTOCOL_VERSION,
|
|
@@ -40682,6 +40706,14 @@ class AuthBrokerClient2 {
|
|
|
40682
40706
|
});
|
|
40683
40707
|
return data;
|
|
40684
40708
|
}
|
|
40709
|
+
async listMicrosoftAccounts() {
|
|
40710
|
+
const data = await this.send({
|
|
40711
|
+
v: PROTOCOL_VERSION,
|
|
40712
|
+
id: randomUUID4(),
|
|
40713
|
+
op: "list-microsoft-accounts"
|
|
40714
|
+
});
|
|
40715
|
+
return data;
|
|
40716
|
+
}
|
|
40685
40717
|
async probeQuota(accounts, timeoutMs) {
|
|
40686
40718
|
const data = await this.send({
|
|
40687
40719
|
v: PROTOCOL_VERSION,
|
|
@@ -47227,6 +47259,30 @@ function decideInboundDelivery(input) {
|
|
|
47227
47259
|
return "deliver";
|
|
47228
47260
|
}
|
|
47229
47261
|
|
|
47262
|
+
// gateway/inbound-delivery-confirm.ts
|
|
47263
|
+
function createDeliveryQueue() {
|
|
47264
|
+
return { pending: new Map };
|
|
47265
|
+
}
|
|
47266
|
+
function trackDelivery(q, key, inbound, now) {
|
|
47267
|
+
q.pending.set(key, { key, inbound, lastAttemptAt: now });
|
|
47268
|
+
}
|
|
47269
|
+
function ackDelivery(q, key) {
|
|
47270
|
+
return q.pending.delete(key);
|
|
47271
|
+
}
|
|
47272
|
+
function sweep(q, now, timeoutMs) {
|
|
47273
|
+
const redeliver = [];
|
|
47274
|
+
for (const entry of q.pending.values()) {
|
|
47275
|
+
if (now - entry.lastAttemptAt < timeoutMs)
|
|
47276
|
+
continue;
|
|
47277
|
+
entry.lastAttemptAt = now;
|
|
47278
|
+
redeliver.push(entry);
|
|
47279
|
+
}
|
|
47280
|
+
return redeliver;
|
|
47281
|
+
}
|
|
47282
|
+
function forgetDelivery(q, key) {
|
|
47283
|
+
q.pending.delete(key);
|
|
47284
|
+
}
|
|
47285
|
+
|
|
47230
47286
|
// gateway/pending-permission-decisions.ts
|
|
47231
47287
|
var DEFAULT_PENDING_PERMISSION_CAP = 32;
|
|
47232
47288
|
function createPendingPermissionBuffer(opts = {}) {
|
|
@@ -47411,12 +47467,12 @@ function transition(state3, event) {
|
|
|
47411
47467
|
turnStartedAt: null,
|
|
47412
47468
|
lastOutboundAt: event.outboundEmitted ? event.at : p.lastOutboundAt
|
|
47413
47469
|
}));
|
|
47414
|
-
const
|
|
47470
|
+
const sweep2 = sweepSiblings(stateAfterClear, chatId, event.key);
|
|
47415
47471
|
const wasActive = state3.global.kind === "bridge_alive_in_turn" && state3.global.activeTurn === event.key;
|
|
47416
|
-
const next = wasActive ? { ...
|
|
47472
|
+
const next = wasActive ? { ...sweep2.state, global: { kind: "bridge_alive_idle" } } : sweep2.state;
|
|
47417
47473
|
const effects = [
|
|
47418
47474
|
{ kind: "clearTurnStarted", key: event.key },
|
|
47419
|
-
...
|
|
47475
|
+
...sweep2.effects
|
|
47420
47476
|
];
|
|
47421
47477
|
if (event.outboundEmitted) {
|
|
47422
47478
|
effects.push({ kind: "noteOutbound", key: event.key, at: event.at });
|
|
@@ -51794,10 +51850,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51794
51850
|
}
|
|
51795
51851
|
|
|
51796
51852
|
// ../src/build-info.ts
|
|
51797
|
-
var VERSION = "0.14.
|
|
51798
|
-
var COMMIT_SHA = "
|
|
51799
|
-
var COMMIT_DATE = "2026-06-
|
|
51800
|
-
var LATEST_PR =
|
|
51853
|
+
var VERSION = "0.14.40";
|
|
51854
|
+
var COMMIT_SHA = "d2d69140";
|
|
51855
|
+
var COMMIT_DATE = "2026-06-02T08:59:46Z";
|
|
51856
|
+
var LATEST_PR = 2090;
|
|
51801
51857
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51802
51858
|
|
|
51803
51859
|
// gateway/boot-version.ts
|
|
@@ -52967,8 +53023,14 @@ function markClaudeBusyForInbound(m) {
|
|
|
52967
53023
|
if (Number.isFinite(n))
|
|
52968
53024
|
tid = n;
|
|
52969
53025
|
}
|
|
52970
|
-
|
|
53026
|
+
const key = chatKey2(m.chatId, tid);
|
|
53027
|
+
claudeBusyKeys.add(key);
|
|
53028
|
+
return key;
|
|
52971
53029
|
}
|
|
53030
|
+
var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
|
|
53031
|
+
var DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15000;
|
|
53032
|
+
var DELIVERY_CONFIRM_SWEEP_MS = 5000;
|
|
53033
|
+
var deliveryQueue = createDeliveryQueue();
|
|
52972
53034
|
function turnInFlightForGate() {
|
|
52973
53035
|
return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
|
|
52974
53036
|
}
|
|
@@ -54088,6 +54150,29 @@ var _deliveryMachineTick = setInterval(() => {
|
|
|
54088
54150
|
shadowEmit({ kind: "tick", now: Date.now() });
|
|
54089
54151
|
}, DELIVERY_MACHINE_TICK_MS);
|
|
54090
54152
|
_deliveryMachineTick.unref?.();
|
|
54153
|
+
async function redeliverStrandedInbound(p) {
|
|
54154
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
54155
|
+
process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
|
|
54156
|
+
`);
|
|
54157
|
+
try {
|
|
54158
|
+
const { clearAgentComposer: clearAgentComposer2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
|
|
54159
|
+
if (selfAgent)
|
|
54160
|
+
clearAgentComposer2({ agentName: selfAgent });
|
|
54161
|
+
} catch {}
|
|
54162
|
+
const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
|
|
54163
|
+
if (!ok) {
|
|
54164
|
+
pendingInboundBuffer.push(selfAgent, p.inbound);
|
|
54165
|
+
forgetDelivery(deliveryQueue, p.key);
|
|
54166
|
+
}
|
|
54167
|
+
}
|
|
54168
|
+
var _deliveryConfirmSweep = setInterval(() => {
|
|
54169
|
+
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54170
|
+
return;
|
|
54171
|
+
for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
54172
|
+
redeliverStrandedInbound(p);
|
|
54173
|
+
}
|
|
54174
|
+
}, DELIVERY_CONFIRM_SWEEP_MS);
|
|
54175
|
+
_deliveryConfirmSweep.unref?.();
|
|
54091
54176
|
startTimer2({
|
|
54092
54177
|
editMessage: async (ctx) => {
|
|
54093
54178
|
const editOpts = ctx.parseMode != null ? { parse_mode: ctx.parseMode } : undefined;
|
|
@@ -56478,6 +56563,9 @@ function handleSessionEvent(ev) {
|
|
|
56478
56563
|
isDm: isDmChatId(ev.chatId)
|
|
56479
56564
|
};
|
|
56480
56565
|
currentTurn = next;
|
|
56566
|
+
if (DELIVERY_CONFIRM_ENABLED) {
|
|
56567
|
+
ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
|
|
56568
|
+
}
|
|
56481
56569
|
shadowEmit({
|
|
56482
56570
|
kind: "turnStart",
|
|
56483
56571
|
key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined),
|
|
@@ -57910,8 +57998,12 @@ ${preBlock(write.output)}`;
|
|
|
57910
57998
|
}
|
|
57911
57999
|
}
|
|
57912
58000
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
|
|
57913
|
-
if (delivered)
|
|
57914
|
-
markClaudeBusyForInbound(inboundMsg);
|
|
58001
|
+
if (delivered) {
|
|
58002
|
+
const busyKey = markClaudeBusyForInbound(inboundMsg);
|
|
58003
|
+
if (DELIVERY_CONFIRM_ENABLED) {
|
|
58004
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now());
|
|
58005
|
+
}
|
|
58006
|
+
}
|
|
57915
58007
|
if (!delivered) {
|
|
57916
58008
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
57917
58009
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
|
|
@@ -280,6 +280,14 @@ import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } f
|
|
|
280
280
|
import { createInboundSpool } from './inbound-spool.js'
|
|
281
281
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
282
282
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
283
|
+
import {
|
|
284
|
+
createDeliveryQueue,
|
|
285
|
+
trackDelivery,
|
|
286
|
+
ackDelivery,
|
|
287
|
+
sweep as sweepDeliveryQueue,
|
|
288
|
+
forgetDelivery,
|
|
289
|
+
type PendingDelivery,
|
|
290
|
+
} from './inbound-delivery-confirm.js'
|
|
283
291
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
284
292
|
import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
|
|
285
293
|
// Phase 2b PR 2 — shadow mode. Each event-site below calls shadowEmit()
|
|
@@ -1309,15 +1317,35 @@ function markClaudeBusyForInbound(m: {
|
|
|
1309
1317
|
chatId: string
|
|
1310
1318
|
threadId?: number
|
|
1311
1319
|
meta?: Record<string, string>
|
|
1312
|
-
}):
|
|
1320
|
+
}): string {
|
|
1313
1321
|
let tid: number | null = m.threadId ?? null
|
|
1314
1322
|
if (tid == null && m.meta?.message_thread_id != null) {
|
|
1315
1323
|
const n = Number(m.meta.message_thread_id)
|
|
1316
1324
|
if (Number.isFinite(n)) tid = n
|
|
1317
1325
|
}
|
|
1318
|
-
|
|
1326
|
+
const key = chatKey(m.chatId, tid)
|
|
1327
|
+
claudeBusyKeys.add(key)
|
|
1328
|
+
return key
|
|
1319
1329
|
}
|
|
1320
1330
|
|
|
1331
|
+
// ─── Reliable inbound delivery: deliver-until-acked (the marko drop-wedge) ─
|
|
1332
|
+
// A delivered inbound is ACKED only by the `enqueue` session-event (claude
|
|
1333
|
+
// actually started the turn) — NOT by sendToAgent returning true. Until
|
|
1334
|
+
// acked, the message stays queued; if it didn't land within the timeout it
|
|
1335
|
+
// stranded in claude's composer, so we re-deliver it. Re-deliver forever
|
|
1336
|
+
// until acked — never drop (vs the old fire-and-forget that the 300s
|
|
1337
|
+
// silence-poke swallowed). Kill switch: SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0
|
|
1338
|
+
// → legacy fire-and-forget. See inbound-delivery-confirm.ts.
|
|
1339
|
+
const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== '0'
|
|
1340
|
+
// How long to wait for claude's `enqueue` ack before treating a delivery as
|
|
1341
|
+
// stranded and re-delivering. Generous by default — claude acks within ~1s of
|
|
1342
|
+
// a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
|
|
1343
|
+
// (env) for tests/forensics; a too-low value re-delivers healthy slow turns
|
|
1344
|
+
// (duplicate turn), which is why the default is comfortably above ack latency.
|
|
1345
|
+
const DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15_000
|
|
1346
|
+
const DELIVERY_CONFIRM_SWEEP_MS = 5_000
|
|
1347
|
+
const deliveryQueue = createDeliveryQueue<InboundMessage>()
|
|
1348
|
+
|
|
1321
1349
|
/**
|
|
1322
1350
|
* Authoritative "is a turn in flight?" for every gate that previously
|
|
1323
1351
|
* read `claudeBusyKeys.size`. PR 3b cutover (extends PR 3a's bridgeUp
|
|
@@ -4061,6 +4089,38 @@ const _deliveryMachineTick = setInterval(() => {
|
|
|
4061
4089
|
}, DELIVERY_MACHINE_TICK_MS)
|
|
4062
4090
|
_deliveryMachineTick.unref?.()
|
|
4063
4091
|
|
|
4092
|
+
// Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
|
|
4093
|
+
// Every few seconds, re-send any inbound that was handed to claude but never
|
|
4094
|
+
// acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
|
|
4095
|
+
// the composer so the re-sent notification lands on a clean line, then
|
|
4096
|
+
// re-send. Reuses the same delivery primitives; the message is never dropped.
|
|
4097
|
+
// (Refs ipcServer / pendingInboundBuffer declared below — resolved at fire
|
|
4098
|
+
// time, after module init.) unref so the interval never holds the process.
|
|
4099
|
+
async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Promise<void> {
|
|
4100
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4101
|
+
process.stderr.write(
|
|
4102
|
+
`telegram gateway: inbound strand (no enqueue ack) key=${p.key} — re-clearing composer + re-delivering\n`,
|
|
4103
|
+
)
|
|
4104
|
+
try {
|
|
4105
|
+
const { clearAgentComposer } = await import('../../src/agents/tmux.js')
|
|
4106
|
+
if (selfAgent) clearAgentComposer({ agentName: selfAgent })
|
|
4107
|
+
} catch { /* best-effort; re-deliver regardless */ }
|
|
4108
|
+
const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
|
|
4109
|
+
if (!ok) {
|
|
4110
|
+
// Bridge offline between attempts — hand off to the offline buffer
|
|
4111
|
+
// (bridgeUp drains it) and stop tracking here; the spool owns it now.
|
|
4112
|
+
pendingInboundBuffer.push(selfAgent, p.inbound)
|
|
4113
|
+
forgetDelivery(deliveryQueue, p.key)
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
const _deliveryConfirmSweep = setInterval(() => {
|
|
4117
|
+
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
4118
|
+
for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
4119
|
+
void redeliverStrandedInbound(p)
|
|
4120
|
+
}
|
|
4121
|
+
}, DELIVERY_CONFIRM_SWEEP_MS)
|
|
4122
|
+
_deliveryConfirmSweep.unref?.()
|
|
4123
|
+
|
|
4064
4124
|
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
4065
4125
|
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
4066
4126
|
// background) and the model has stopped speaking, keep editing the
|
|
@@ -7880,6 +7940,16 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7880
7940
|
isDm: isDmChatId(ev.chatId),
|
|
7881
7941
|
}
|
|
7882
7942
|
currentTurn = next
|
|
7943
|
+
// Ack inbound delivery (the marko drop-wedge): claude actually started
|
|
7944
|
+
// this turn, so its delivered inbound landed — stop tracking it for
|
|
7945
|
+
// re-delivery. `enqueue` carries the same chat/thread the inbound was
|
|
7946
|
+
// keyed on, so the key matches.
|
|
7947
|
+
if (DELIVERY_CONFIRM_ENABLED) {
|
|
7948
|
+
ackDelivery(
|
|
7949
|
+
deliveryQueue,
|
|
7950
|
+
chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
|
|
7951
|
+
)
|
|
7952
|
+
}
|
|
7883
7953
|
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
7884
7954
|
// machine. `enqueue` fires for EVERY turn atom regardless of
|
|
7885
7955
|
// source — inbound, cron, subagent-handback, vault-resume,
|
|
@@ -10596,7 +10666,15 @@ async function handleInbound(
|
|
|
10596
10666
|
}
|
|
10597
10667
|
|
|
10598
10668
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
10599
|
-
if (delivered)
|
|
10669
|
+
if (delivered) {
|
|
10670
|
+
const busyKey = markClaudeBusyForInbound(inboundMsg)
|
|
10671
|
+
// Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
|
|
10672
|
+
// lands, the message stranded in the composer and the sweep re-delivers
|
|
10673
|
+
// it. See inbound-delivery-confirm.ts.
|
|
10674
|
+
if (DELIVERY_CONFIRM_ENABLED) {
|
|
10675
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now())
|
|
10676
|
+
}
|
|
10677
|
+
}
|
|
10600
10678
|
if (!delivered) {
|
|
10601
10679
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
10602
10680
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reliable inbound delivery: deliver-until-acked (the marko drop-wedge).
|
|
3
|
+
*
|
|
4
|
+
* Delivering an inbound to claude is fire-and-forget: the gateway calls
|
|
5
|
+
* `sendToAgent`, the bridge turns it into an MCP channel notification, and
|
|
6
|
+
* the unmodified CLI appends the text into its composer and auto-submits
|
|
7
|
+
* ONLY when the composer is empty + idle. If the message lands while claude
|
|
8
|
+
* is still finalizing the prior turn, the auto-submit races turn-completion
|
|
9
|
+
* and the text strands unsubmitted — claude never starts the turn, so the
|
|
10
|
+
* gateway eventually drops the message at the 300s silence-poke. Observed
|
|
11
|
+
* recurring on `marko` (supergroup topic + DMs alike).
|
|
12
|
+
*
|
|
13
|
+
* This is the queue that makes delivery reliable. The contract is the whole
|
|
14
|
+
* idea, and it is deliberately small:
|
|
15
|
+
*
|
|
16
|
+
* 1. A delivered inbound is ACKED only when claude actually starts the
|
|
17
|
+
* turn — the `enqueue` session-event (the one signal that claude truly
|
|
18
|
+
* picked the message up). NOT when `sendToAgent` returns true.
|
|
19
|
+
* 2. Until acked, the message stays tracked. If it hasn't been acked
|
|
20
|
+
* within `timeoutMs`, it stranded: re-deliver it (the gateway re-clears
|
|
21
|
+
* the composer and re-sends).
|
|
22
|
+
* 3. Re-deliver as many times as it takes. We never drop the message and
|
|
23
|
+
* never give up — a reliable queue keeps the message until it lands.
|
|
24
|
+
*
|
|
25
|
+
* Keyed per `chatKey(chatId, threadId)`, so DMs and supergroup forum topics
|
|
26
|
+
* are handled identically (the key is opaque here). The #1556 gate
|
|
27
|
+
* serialises delivery per key, so at most one delivery per key is in flight.
|
|
28
|
+
*
|
|
29
|
+
* Pure bookkeeping only — the gateway does the actual composer-clear and
|
|
30
|
+
* re-send for whatever `sweep` returns. Unit-tested in isolation.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface PendingDelivery<M> {
|
|
34
|
+
/** chatKey(chatId, threadId) — opaque to this module. */
|
|
35
|
+
readonly key: string
|
|
36
|
+
/** The exact inbound to re-send until claude acks it. */
|
|
37
|
+
readonly inbound: M
|
|
38
|
+
/** When the latest delivery attempt was made (unix-ms). */
|
|
39
|
+
lastAttemptAt: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DeliveryQueue<M> {
|
|
43
|
+
readonly pending: Map<string, PendingDelivery<M>>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createDeliveryQueue<M>(): DeliveryQueue<M> {
|
|
47
|
+
return { pending: new Map() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Track a freshly-delivered inbound, awaiting claude's `enqueue` ack.
|
|
52
|
+
* Overwrites any prior pending for the key — the #1556 gate serialises per
|
|
53
|
+
* key, so a later inbound supersedes an earlier un-acked one for that key.
|
|
54
|
+
*/
|
|
55
|
+
export function trackDelivery<M>(
|
|
56
|
+
q: DeliveryQueue<M>,
|
|
57
|
+
key: string,
|
|
58
|
+
inbound: M,
|
|
59
|
+
now: number,
|
|
60
|
+
): void {
|
|
61
|
+
q.pending.set(key, { key, inbound, lastAttemptAt: now })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ack a delivery — call from the `enqueue` session-event (claude started the
|
|
66
|
+
* turn, so the message landed). Returns true if a pending entry was cleared.
|
|
67
|
+
*/
|
|
68
|
+
export function ackDelivery<M>(q: DeliveryQueue<M>, key: string): boolean {
|
|
69
|
+
return q.pending.delete(key)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return the inbounds that stranded (no ack within `timeoutMs`) and should be
|
|
74
|
+
* re-delivered now. Resets each returned entry's clock so the next sweep
|
|
75
|
+
* waits another full `timeoutMs` — the gateway re-sends them. Entries still
|
|
76
|
+
* within the window are left untouched (claude may yet be picking them up).
|
|
77
|
+
*/
|
|
78
|
+
export function sweep<M>(
|
|
79
|
+
q: DeliveryQueue<M>,
|
|
80
|
+
now: number,
|
|
81
|
+
timeoutMs: number,
|
|
82
|
+
): PendingDelivery<M>[] {
|
|
83
|
+
const redeliver: PendingDelivery<M>[] = []
|
|
84
|
+
for (const entry of q.pending.values()) {
|
|
85
|
+
if (now - entry.lastAttemptAt < timeoutMs) continue
|
|
86
|
+
entry.lastAttemptAt = now
|
|
87
|
+
redeliver.push(entry)
|
|
88
|
+
}
|
|
89
|
+
return redeliver
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Forget a key without acking (e.g. the bridge went offline and the message
|
|
93
|
+
* was handed back to the offline buffer, which owns it now). */
|
|
94
|
+
export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
|
|
95
|
+
q.pending.delete(key)
|
|
96
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ackDelivery,
|
|
5
|
+
createDeliveryQueue,
|
|
6
|
+
forgetDelivery,
|
|
7
|
+
sweep,
|
|
8
|
+
trackDelivery,
|
|
9
|
+
type DeliveryQueue,
|
|
10
|
+
} from '../gateway/inbound-delivery-confirm.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression coverage for the marko drop-wedge.
|
|
14
|
+
*
|
|
15
|
+
* An inbound delivered to claude's TUI composer strands unsubmitted when the
|
|
16
|
+
* auto-submit races turn-completion. claude never emits `enqueue`, so the
|
|
17
|
+
* gateway used to sit "typing…" for 300s then DROP the message.
|
|
18
|
+
*
|
|
19
|
+
* The queue's contract: a delivered inbound is acked ONLY by `enqueue`; until
|
|
20
|
+
* then it is re-delivered every `timeoutMs`, forever, never dropped — and an
|
|
21
|
+
* acked delivery never re-fires (no duplicate turns).
|
|
22
|
+
*/
|
|
23
|
+
type Msg = { text: string }
|
|
24
|
+
const TIMEOUT = 15_000
|
|
25
|
+
|
|
26
|
+
function fresh(): DeliveryQueue<Msg> {
|
|
27
|
+
return createDeliveryQueue<Msg>()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () => {
|
|
31
|
+
it('an acked delivery is never re-delivered (happy path — no duplicate turns)', () => {
|
|
32
|
+
const q = fresh()
|
|
33
|
+
trackDelivery(q, 'chat:_', { text: 'hi' }, 1_000)
|
|
34
|
+
expect(ackDelivery(q, 'chat:_')).toBe(true) // enqueue arrived
|
|
35
|
+
expect(sweep(q, 1_000 + 999_999, TIMEOUT)).toHaveLength(0)
|
|
36
|
+
expect(q.pending.size).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('within the timeout, an un-acked delivery is left alone (claude may still be picking it up)', () => {
|
|
40
|
+
const q = fresh()
|
|
41
|
+
trackDelivery(q, 'chat:_', { text: 'hi' }, 1_000)
|
|
42
|
+
expect(sweep(q, 1_000 + 14_999, TIMEOUT)).toHaveLength(0)
|
|
43
|
+
expect(q.pending.size).toBe(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('a strand (no ack) is re-delivered after the timeout, and the clock resets', () => {
|
|
47
|
+
const q = fresh()
|
|
48
|
+
trackDelivery(q, 'chat:_', { text: 'draft nurture email' }, 1_000)
|
|
49
|
+
const r = sweep(q, 1_000 + 15_000, TIMEOUT)
|
|
50
|
+
expect(r).toHaveLength(1)
|
|
51
|
+
expect(r[0]!.inbound.text).toBe('draft nurture email')
|
|
52
|
+
expect(r[0]!.lastAttemptAt).toBe(1_000 + 15_000) // clock reset
|
|
53
|
+
// not re-swept until another full timeout elapses
|
|
54
|
+
expect(sweep(q, 1_000 + 15_000 + 14_999, TIMEOUT)).toHaveLength(0)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps re-delivering forever until acked — never drops (the reliability invariant)', () => {
|
|
58
|
+
const q = fresh()
|
|
59
|
+
let t = 0
|
|
60
|
+
trackDelivery(q, 'chat:_', { text: 'x' }, t)
|
|
61
|
+
for (let i = 0; i < 50; i++) {
|
|
62
|
+
t += 15_000
|
|
63
|
+
expect(sweep(q, t, TIMEOUT)).toHaveLength(1) // still trying after 50 strands
|
|
64
|
+
}
|
|
65
|
+
expect(q.pending.size).toBe(1) // never dropped
|
|
66
|
+
// claude finally picks it up → acked → stops.
|
|
67
|
+
expect(ackDelivery(q, 'chat:_')).toBe(true)
|
|
68
|
+
expect(sweep(q, t + 999_999, TIMEOUT)).toHaveLength(0)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('an ack that lands right after a re-delivery stops further re-delivery (no duplicate turns)', () => {
|
|
72
|
+
const q = fresh()
|
|
73
|
+
trackDelivery(q, 'chat:_', { text: 'x' }, 0)
|
|
74
|
+
sweep(q, 15_000, TIMEOUT) // strand → re-delivered
|
|
75
|
+
expect(ackDelivery(q, 'chat:_')).toBe(true) // the re-delivered copy landed
|
|
76
|
+
expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
|
|
77
|
+
expect(q.pending.size).toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('keys are independent — a strand on one topic does not affect another (DM + supergroup topics)', () => {
|
|
81
|
+
const q = fresh()
|
|
82
|
+
trackDelivery(q, '-100:4', { text: 'crm topic msg' }, 0) // supergroup CRM topic
|
|
83
|
+
trackDelivery(q, '555:_', { text: 'dm msg' }, 0) // a DM
|
|
84
|
+
ackDelivery(q, '555:_') // the DM submits fine
|
|
85
|
+
const r = sweep(q, 15_000, TIMEOUT)
|
|
86
|
+
expect(r).toHaveLength(1)
|
|
87
|
+
expect(r[0]!.key).toBe('-100:4') // only the stranded topic re-delivers
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('tracking the same key twice keeps only the latest inbound (gate serialises per key)', () => {
|
|
91
|
+
const q = fresh()
|
|
92
|
+
trackDelivery(q, 'chat:_', { text: 'first' }, 0)
|
|
93
|
+
trackDelivery(q, 'chat:_', { text: 'second' }, 100)
|
|
94
|
+
expect(q.pending.size).toBe(1)
|
|
95
|
+
expect(sweep(q, 100 + 15_000, TIMEOUT)[0]!.inbound.text).toBe('second')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('ack on an unknown key is a harmless no-op', () => {
|
|
99
|
+
expect(ackDelivery(fresh(), 'never-tracked')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('forgetDelivery clears without acking or re-delivering (bridge went offline)', () => {
|
|
103
|
+
const q = fresh()
|
|
104
|
+
trackDelivery(q, 'chat:_', { text: 'x' }, 0)
|
|
105
|
+
forgetDelivery(q, 'chat:_')
|
|
106
|
+
expect(q.pending.size).toBe(0)
|
|
107
|
+
expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
|
|
108
|
+
})
|
|
109
|
+
})
|