switchroom 0.14.47 → 0.14.48
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 +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +52 -21
- package/telegram-plugin/gateway/gateway.ts +65 -1
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +12 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +17 -1
- package/telegram-plugin/gateway/resume-inbound-builder.ts +20 -4
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +27 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +19 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49462,8 +49462,8 @@ var {
|
|
|
49462
49462
|
} = import__.default;
|
|
49463
49463
|
|
|
49464
49464
|
// src/build-info.ts
|
|
49465
|
-
var VERSION = "0.14.
|
|
49466
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.48";
|
|
49466
|
+
var COMMIT_SHA = "a6517652";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -46927,7 +46927,7 @@ function escapeHtml7(s) {
|
|
|
46927
46927
|
|
|
46928
46928
|
// gateway/pending-inbound-buffer.ts
|
|
46929
46929
|
var DEFAULT_PENDING_INBOUND_CAP = 32;
|
|
46930
|
-
function redeliverBufferedInbound(buffer, agent, send, spool) {
|
|
46930
|
+
function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
46931
46931
|
const pending = buffer.drain(agent);
|
|
46932
46932
|
let redelivered = 0;
|
|
46933
46933
|
let rebuffered = 0;
|
|
@@ -46942,6 +46942,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
|
|
|
46942
46942
|
for (const o of originals)
|
|
46943
46943
|
spool?.ack(o);
|
|
46944
46944
|
redelivered += originals.length;
|
|
46945
|
+
onDelivered?.(merged, originals);
|
|
46945
46946
|
} else {
|
|
46946
46947
|
for (const o of originals)
|
|
46947
46948
|
buffer.push(agent, o);
|
|
@@ -47004,14 +47005,14 @@ function mergeRun(run2) {
|
|
|
47004
47005
|
merged.attachment = mediaEntry.attachment;
|
|
47005
47006
|
return merged;
|
|
47006
47007
|
}
|
|
47007
|
-
function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
|
|
47008
|
+
function idleDrainTick(buffer, agent, isBridgeAlive, send, spool, onDelivered) {
|
|
47008
47009
|
if (!agent)
|
|
47009
47010
|
return null;
|
|
47010
47011
|
if (buffer.depth(agent) === 0)
|
|
47011
47012
|
return null;
|
|
47012
47013
|
if (!isBridgeAlive())
|
|
47013
47014
|
return null;
|
|
47014
|
-
return redeliverBufferedInbound(buffer, agent, send, spool);
|
|
47015
|
+
return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered);
|
|
47015
47016
|
}
|
|
47016
47017
|
function createPendingInboundBuffer(opts = {}) {
|
|
47017
47018
|
const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP;
|
|
@@ -47618,7 +47619,7 @@ function formatEventDetail(event) {
|
|
|
47618
47619
|
}
|
|
47619
47620
|
|
|
47620
47621
|
// gateway/pending-inbound-buffer.ts
|
|
47621
|
-
function redeliverBufferedInbound2(buffer, agent, send, spool) {
|
|
47622
|
+
function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
|
|
47622
47623
|
const pending = buffer.drain(agent);
|
|
47623
47624
|
let redelivered = 0;
|
|
47624
47625
|
let rebuffered = 0;
|
|
@@ -47633,6 +47634,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
|
|
|
47633
47634
|
for (const o of originals)
|
|
47634
47635
|
spool?.ack(o);
|
|
47635
47636
|
redelivered += originals.length;
|
|
47637
|
+
onDelivered?.(merged, originals);
|
|
47636
47638
|
} else {
|
|
47637
47639
|
for (const o of originals)
|
|
47638
47640
|
buffer.push(agent, o);
|
|
@@ -47725,7 +47727,7 @@ function dispatchOne(effect, ctx) {
|
|
|
47725
47727
|
}
|
|
47726
47728
|
return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg);
|
|
47727
47729
|
};
|
|
47728
|
-
const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined);
|
|
47730
|
+
const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined, ctx.onUserInboundDelivered ? (merged) => ctx.onUserInboundDelivered(merged) : undefined);
|
|
47729
47731
|
if (result.drained > 0) {
|
|
47730
47732
|
log(`telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` + `drained=${result.drained} redelivered=${result.redelivered} ` + `rebuffered=${result.rebuffered}
|
|
47731
47733
|
`);
|
|
@@ -52097,10 +52099,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52097
52099
|
}
|
|
52098
52100
|
|
|
52099
52101
|
// ../src/build-info.ts
|
|
52100
|
-
var VERSION = "0.14.
|
|
52101
|
-
var COMMIT_SHA = "
|
|
52102
|
-
var COMMIT_DATE = "2026-06-03T07:
|
|
52103
|
-
var LATEST_PR =
|
|
52102
|
+
var VERSION = "0.14.48";
|
|
52103
|
+
var COMMIT_SHA = "a6517652";
|
|
52104
|
+
var COMMIT_DATE = "2026-06-03T07:57:29Z";
|
|
52105
|
+
var LATEST_PR = 2120;
|
|
52104
52106
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52105
52107
|
|
|
52106
52108
|
// gateway/boot-version.ts
|
|
@@ -52665,14 +52667,20 @@ function buildResumeWatchdogReportInbound(ctx) {
|
|
|
52665
52667
|
meta
|
|
52666
52668
|
};
|
|
52667
52669
|
}
|
|
52668
|
-
function selectResumeBuilder(endedVia) {
|
|
52670
|
+
function selectResumeBuilder(endedVia, opts) {
|
|
52671
|
+
let kind;
|
|
52669
52672
|
if (endedVia === "timeout")
|
|
52673
|
+
kind = "report";
|
|
52674
|
+
else if (endedVia === "restart" || endedVia === "sigterm" || endedVia === "unknown")
|
|
52675
|
+
kind = "resume";
|
|
52676
|
+
else if (endedVia == null)
|
|
52677
|
+
kind = "resume";
|
|
52678
|
+
else
|
|
52679
|
+
kind = null;
|
|
52680
|
+
if (kind === "resume" && opts?.ageMs != null && opts?.maxAgeMs != null && opts.ageMs > opts.maxAgeMs) {
|
|
52670
52681
|
return "report";
|
|
52671
|
-
|
|
52672
|
-
|
|
52673
|
-
if (endedVia == null)
|
|
52674
|
-
return "resume";
|
|
52675
|
-
return null;
|
|
52682
|
+
}
|
|
52683
|
+
return kind;
|
|
52676
52684
|
}
|
|
52677
52685
|
|
|
52678
52686
|
// registry/subagents-schema.ts
|
|
@@ -53123,7 +53131,14 @@ try {
|
|
|
53123
53131
|
const pending2 = findLatestTurnIfInterrupted(turnsDb);
|
|
53124
53132
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53125
53133
|
if (pending2 != null && selfAgent) {
|
|
53126
|
-
const
|
|
53134
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
53135
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS);
|
|
53136
|
+
return Number.isFinite(v) && v > 0 ? v : 10800000;
|
|
53137
|
+
})();
|
|
53138
|
+
const kind = selectResumeBuilder(pending2.ended_via, {
|
|
53139
|
+
ageMs: Math.max(0, Date.now() - pending2.started_at),
|
|
53140
|
+
maxAgeMs: RESUME_MAX_AGE_MS
|
|
53141
|
+
});
|
|
53127
53142
|
if (kind === "resume") {
|
|
53128
53143
|
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending2 }) };
|
|
53129
53144
|
} else if (kind === "report") {
|
|
@@ -53390,7 +53405,7 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
53390
53405
|
if (d)
|
|
53391
53406
|
markClaudeBusyForInbound(m);
|
|
53392
53407
|
return d;
|
|
53393
|
-
}, inboundSpool);
|
|
53408
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
53394
53409
|
if (fr.redelivered > 0) {
|
|
53395
53410
|
process.stderr.write(`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53396
53411
|
`);
|
|
@@ -53420,7 +53435,7 @@ function releaseTurnBufferGate(key) {
|
|
|
53420
53435
|
if (d)
|
|
53421
53436
|
markClaudeBusyForInbound(m);
|
|
53422
53437
|
return d;
|
|
53423
|
-
}, inboundSpool);
|
|
53438
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
53424
53439
|
if (fr.redelivered > 0) {
|
|
53425
53440
|
process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53426
53441
|
`);
|
|
@@ -54406,7 +54421,7 @@ startTimer({
|
|
|
54406
54421
|
if (d)
|
|
54407
54422
|
markClaudeBusyForInbound(m);
|
|
54408
54423
|
return d;
|
|
54409
|
-
}, inboundSpool);
|
|
54424
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
54410
54425
|
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}` : ""}
|
|
54411
54426
|
`);
|
|
54412
54427
|
}
|
|
@@ -54416,6 +54431,20 @@ var _deliveryMachineTick = setInterval(() => {
|
|
|
54416
54431
|
shadowEmit({ kind: "tick", now: Date.now() });
|
|
54417
54432
|
}, DELIVERY_MACHINE_TICK_MS);
|
|
54418
54433
|
_deliveryMachineTick.unref?.();
|
|
54434
|
+
function trackRedeliveredInbound(merged) {
|
|
54435
|
+
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54436
|
+
return;
|
|
54437
|
+
if (!shouldTrackDelivery({
|
|
54438
|
+
isSteering: false,
|
|
54439
|
+
isInterrupt: false,
|
|
54440
|
+
hasSource: merged.meta?.source != null,
|
|
54441
|
+
effectiveText: merged.text
|
|
54442
|
+
})) {
|
|
54443
|
+
return;
|
|
54444
|
+
}
|
|
54445
|
+
const key = chatKey2(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null);
|
|
54446
|
+
trackDelivery(deliveryQueue, key, merged, Date.now(), merged.messageId != null ? String(merged.messageId) : null);
|
|
54447
|
+
}
|
|
54419
54448
|
async function redeliverStrandedInbound(p) {
|
|
54420
54449
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
54421
54450
|
process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
|
|
@@ -54522,7 +54551,8 @@ var ipcServer = createIpcServer({
|
|
|
54522
54551
|
pendingInboundBuffer,
|
|
54523
54552
|
inboundSpool: inboundSpool ?? null,
|
|
54524
54553
|
pendingPermissionBuffer,
|
|
54525
|
-
client: client3
|
|
54554
|
+
client: client3,
|
|
54555
|
+
onUserInboundDelivered: trackRedeliveredInbound
|
|
54526
54556
|
});
|
|
54527
54557
|
} else {
|
|
54528
54558
|
const pending2 = pendingInboundBuffer.drain(client3.agentName);
|
|
@@ -54530,6 +54560,7 @@ var ipcServer = createIpcServer({
|
|
|
54530
54560
|
try {
|
|
54531
54561
|
client3.send(msg);
|
|
54532
54562
|
inboundSpool?.ack(msg);
|
|
54563
|
+
trackRedeliveredInbound(msg);
|
|
54533
54564
|
} catch (err) {
|
|
54534
54565
|
process.stderr.write(`telegram gateway: pending-inbound drain failed agent=${client3.agentName} source=${msg.meta?.source ?? "-"}: ${err.message}
|
|
54535
54566
|
`);
|
|
@@ -55085,7 +55116,7 @@ if (!STATIC) {
|
|
|
55085
55116
|
if (d)
|
|
55086
55117
|
markClaudeBusyForInbound(m);
|
|
55087
55118
|
return d;
|
|
55088
|
-
}, inboundSpool);
|
|
55119
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
55089
55120
|
if (r != null && r.redelivered > 0) {
|
|
55090
55121
|
process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
|
|
55091
55122
|
`);
|
|
@@ -1064,7 +1064,19 @@ try {
|
|
|
1064
1064
|
const pending = findLatestTurnIfInterrupted(turnsDb)
|
|
1065
1065
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1066
1066
|
if (pending != null && selfAgent) {
|
|
1067
|
-
|
|
1067
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
|
|
1068
|
+
// interrupted work older than RESUME_MAX_AGE_MS — selectResumeBuilder
|
|
1069
|
+
// downgrades a stale 'resume' to the passive 'report' so the user is told
|
|
1070
|
+
// ("I was working on X ~Nh ago") but nothing replays unprompted. Env
|
|
1071
|
+
// override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
|
|
1072
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
1073
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1074
|
+
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1075
|
+
})()
|
|
1076
|
+
const kind = selectResumeBuilder(pending.ended_via, {
|
|
1077
|
+
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1078
|
+
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1079
|
+
})
|
|
1068
1080
|
if (kind === 'resume') {
|
|
1069
1081
|
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
|
|
1070
1082
|
} else if (kind === 'report') {
|
|
@@ -1801,6 +1813,7 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1801
1813
|
return d
|
|
1802
1814
|
},
|
|
1803
1815
|
inboundSpool,
|
|
1816
|
+
trackRedeliveredInbound,
|
|
1804
1817
|
)
|
|
1805
1818
|
if (fr.redelivered > 0) {
|
|
1806
1819
|
process.stderr.write(
|
|
@@ -1896,6 +1909,7 @@ function releaseTurnBufferGate(key: string): void {
|
|
|
1896
1909
|
return d
|
|
1897
1910
|
},
|
|
1898
1911
|
inboundSpool,
|
|
1912
|
+
trackRedeliveredInbound,
|
|
1899
1913
|
)
|
|
1900
1914
|
if (fr.redelivered > 0) {
|
|
1901
1915
|
process.stderr.write(
|
|
@@ -4134,6 +4148,7 @@ silencePoke.startTimer({
|
|
|
4134
4148
|
return d
|
|
4135
4149
|
},
|
|
4136
4150
|
inboundSpool,
|
|
4151
|
+
trackRedeliveredInbound,
|
|
4137
4152
|
)
|
|
4138
4153
|
process.stderr.write(
|
|
4139
4154
|
`telegram gateway: silence-poke framework-fallback ended wedged turn ` +
|
|
@@ -4163,6 +4178,45 @@ const _deliveryMachineTick = setInterval(() => {
|
|
|
4163
4178
|
}, DELIVERY_MACHINE_TICK_MS)
|
|
4164
4179
|
_deliveryMachineTick.unref?.()
|
|
4165
4180
|
|
|
4181
|
+
// Enrol a buffer-redelivered inbound in the deliver-until-acked queue so the
|
|
4182
|
+
// existing sweep re-delivers it until claude's `enqueue` ack lands. Wired into
|
|
4183
|
+
// EVERY redelivery path (bridgeUp drain, silence-poke fallback, flap/reply-gate
|
|
4184
|
+
// flushes) — `send` returning true only means the bytes reached the bridge, NOT
|
|
4185
|
+
// that claude consumed them. Right after a restart (esp. a slow MCP boot) the
|
|
4186
|
+
// inject can hit a not-ready session and be silently dropped, and nothing
|
|
4187
|
+
// retried it: the clerk 2026-06-03 lost-message incident. Mirrors the
|
|
4188
|
+
// live-delivery tracking at the handleInbound site (chatKey + messageId), so
|
|
4189
|
+
// DMs and supergroup forum topics are handled identically. Only real user
|
|
4190
|
+
// inbounds are tracked — shouldTrackDelivery excludes steer/interrupt/
|
|
4191
|
+
// synthetic-source/empty, which never produce an `enqueue` and would otherwise
|
|
4192
|
+
// re-deliver forever.
|
|
4193
|
+
function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
4194
|
+
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
4195
|
+
if (
|
|
4196
|
+
!shouldTrackDelivery({
|
|
4197
|
+
isSteering: false,
|
|
4198
|
+
isInterrupt: false,
|
|
4199
|
+
// Synthetic inbounds (cron / vault / handback / resume) carry a source
|
|
4200
|
+
// and are NOT tracked here — they enqueue under their own semantics, and
|
|
4201
|
+
// (for the resume synthetics) tracking them safely first needs the
|
|
4202
|
+
// resume builder to emit meta.message_id so the deliver-until-acked ack
|
|
4203
|
+
// matches its enqueue. Tracked separately as a follow-up (see PR notes).
|
|
4204
|
+
hasSource: merged.meta?.source != null,
|
|
4205
|
+
effectiveText: merged.text,
|
|
4206
|
+
})
|
|
4207
|
+
) {
|
|
4208
|
+
return
|
|
4209
|
+
}
|
|
4210
|
+
const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
|
|
4211
|
+
trackDelivery(
|
|
4212
|
+
deliveryQueue,
|
|
4213
|
+
key,
|
|
4214
|
+
merged,
|
|
4215
|
+
Date.now(),
|
|
4216
|
+
merged.messageId != null ? String(merged.messageId) : null,
|
|
4217
|
+
)
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4166
4220
|
// Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
|
|
4167
4221
|
// Every few seconds, re-send any inbound that was handed to claude but never
|
|
4168
4222
|
// acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
|
|
@@ -4400,6 +4454,11 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4400
4454
|
inboundSpool: inboundSpool ?? null,
|
|
4401
4455
|
pendingPermissionBuffer,
|
|
4402
4456
|
client,
|
|
4457
|
+
// Enrol each drained user inbound in the deliver-until-acked queue
|
|
4458
|
+
// so the 5s sweep re-delivers until claude's `enqueue` ack lands —
|
|
4459
|
+
// a socket-write into a still-booting session is NOT consumption
|
|
4460
|
+
// (clerk lost-message incident, 2026-06-03).
|
|
4461
|
+
onUserInboundDelivered: trackRedeliveredInbound,
|
|
4403
4462
|
})
|
|
4404
4463
|
} else {
|
|
4405
4464
|
// Kill-switch fallback: imperative drain (parity with pre-cutover
|
|
@@ -4410,6 +4469,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4410
4469
|
try {
|
|
4411
4470
|
client.send(msg)
|
|
4412
4471
|
inboundSpool?.ack(msg)
|
|
4472
|
+
// Same enrol as the cutover drain path: a socket-write success is
|
|
4473
|
+
// not proof claude consumed it — enrol so the sweep re-delivers
|
|
4474
|
+
// until `enqueue` (clerk lost-message incident, 2026-06-03).
|
|
4475
|
+
trackRedeliveredInbound(msg)
|
|
4413
4476
|
} catch (err) {
|
|
4414
4477
|
process.stderr.write(
|
|
4415
4478
|
`telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
|
|
@@ -5318,6 +5381,7 @@ if (!STATIC) {
|
|
|
5318
5381
|
return d
|
|
5319
5382
|
},
|
|
5320
5383
|
inboundSpool,
|
|
5384
|
+
trackRedeliveredInbound,
|
|
5321
5385
|
)
|
|
5322
5386
|
if (r != null && r.redelivered > 0) {
|
|
5323
5387
|
process.stderr.write(
|
|
@@ -45,6 +45,15 @@ export interface DispatchCtx {
|
|
|
45
45
|
readonly client?: IpcClient
|
|
46
46
|
/** Optional log sink — default stderr. Test hook. */
|
|
47
47
|
readonly log?: (line: string) => void
|
|
48
|
+
/**
|
|
49
|
+
* Optional: enrol a drained+redelivered inbound in the deliver-until-acked
|
|
50
|
+
* queue. The bridgeUp drain's socket-write "success" is NOT proof claude
|
|
51
|
+
* consumed the message — right after a restart (esp. with a slow MCP boot)
|
|
52
|
+
* the inject can hit a not-ready session and be dropped. Wiring this makes
|
|
53
|
+
* the existing 5s sweep re-deliver until claude's `enqueue` ack lands.
|
|
54
|
+
* (clerk lost-message incident, 2026-06-03.)
|
|
55
|
+
*/
|
|
56
|
+
readonly onUserInboundDelivered?: (merged: InboundMessage) => void
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
|
|
@@ -103,6 +112,9 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
|
|
|
103
112
|
ctx.selfAgent,
|
|
104
113
|
send,
|
|
105
114
|
ctx.inboundSpool ?? undefined,
|
|
115
|
+
ctx.onUserInboundDelivered
|
|
116
|
+
? (merged) => ctx.onUserInboundDelivered!(merged)
|
|
117
|
+
: undefined,
|
|
106
118
|
)
|
|
107
119
|
if (result.drained > 0) {
|
|
108
120
|
log(
|
|
@@ -87,6 +87,14 @@ export function redeliverBufferedInbound(
|
|
|
87
87
|
agent: string,
|
|
88
88
|
send: (msg: InboundMessage) => boolean,
|
|
89
89
|
spool?: InboundSpool,
|
|
90
|
+
// Called once per merged group on CONFIRMED delivery (after spool.ack).
|
|
91
|
+
// The caller uses it to enrol the redelivered inbound in the
|
|
92
|
+
// deliver-until-acked queue (`trackDelivery`) so it is re-sent until
|
|
93
|
+
// claude's `enqueue` ack lands — closing the restart boot-race where a
|
|
94
|
+
// socket-write "succeeds" into a not-ready session and the message is
|
|
95
|
+
// silently dropped (clerk 2026-06-03). `send` returning true only means
|
|
96
|
+
// the bytes reached the bridge, NOT that claude consumed them.
|
|
97
|
+
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
90
98
|
): { drained: number; redelivered: number; rebuffered: number } {
|
|
91
99
|
const pending = buffer.drain(agent)
|
|
92
100
|
let redelivered = 0
|
|
@@ -110,6 +118,10 @@ export function redeliverBufferedInbound(
|
|
|
110
118
|
// originals are, so we ack by original identity.
|
|
111
119
|
for (const o of originals) spool?.ack(o)
|
|
112
120
|
redelivered += originals.length
|
|
121
|
+
// Enrol in the deliver-until-acked queue (caller's hook). A bare
|
|
122
|
+
// socket-write success is NOT proof claude consumed it; the queue's
|
|
123
|
+
// sweep re-delivers until the `enqueue` ack lands.
|
|
124
|
+
onDelivered?.(merged, originals)
|
|
113
125
|
} else {
|
|
114
126
|
// Re-buffer the originals (not the merged synthetic) so the spool
|
|
115
127
|
// identity is preserved and the next drain re-merges them losslessly.
|
|
@@ -258,11 +270,15 @@ export function idleDrainTick(
|
|
|
258
270
|
isBridgeAlive: () => boolean,
|
|
259
271
|
send: (msg: InboundMessage) => boolean,
|
|
260
272
|
spool?: InboundSpool,
|
|
273
|
+
// Forwarded to redeliverBufferedInbound so the post-flap-settle drain also
|
|
274
|
+
// enrols redelivered inbounds in the deliver-until-acked queue (parity with
|
|
275
|
+
// the bridgeUp drain — clerk lost-message incident, 2026-06-03).
|
|
276
|
+
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
261
277
|
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
262
278
|
if (!agent) return null
|
|
263
279
|
if (buffer.depth(agent) === 0) return null
|
|
264
280
|
if (!isBridgeAlive()) return null
|
|
265
|
-
return redeliverBufferedInbound(buffer, agent, send, spool)
|
|
281
|
+
return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered)
|
|
266
282
|
}
|
|
267
283
|
|
|
268
284
|
export function createPendingInboundBuffer(
|
|
@@ -172,9 +172,25 @@ export function buildResumeWatchdogReportInbound(
|
|
|
172
172
|
*/
|
|
173
173
|
export function selectResumeBuilder(
|
|
174
174
|
endedVia: TurnEndedVia | null,
|
|
175
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): when the interrupted
|
|
176
|
+
// turn is older than `maxAgeMs`, an AUTO-resume is downgraded to the passive
|
|
177
|
+
// `report` — silently re-injecting hours-old work could act on long-stale
|
|
178
|
+
// context (a tax figure, a "send it" the user has moved on from). Pass both
|
|
179
|
+
// to enable; omit (default) keeps the legacy blanket-resume behaviour.
|
|
180
|
+
opts?: { ageMs?: number; maxAgeMs?: number },
|
|
175
181
|
): 'resume' | 'report' | null {
|
|
176
|
-
|
|
177
|
-
if (endedVia === '
|
|
178
|
-
if (endedVia
|
|
179
|
-
|
|
182
|
+
let kind: 'resume' | 'report' | null
|
|
183
|
+
if (endedVia === 'timeout') kind = 'report'
|
|
184
|
+
else if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') kind = 'resume'
|
|
185
|
+
else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
|
|
186
|
+
else kind = null
|
|
187
|
+
if (
|
|
188
|
+
kind === 'resume' &&
|
|
189
|
+
opts?.ageMs != null &&
|
|
190
|
+
opts?.maxAgeMs != null &&
|
|
191
|
+
opts.ageMs > opts.maxAgeMs
|
|
192
|
+
) {
|
|
193
|
+
return 'report' // too old to safely auto-resume — passive notice only
|
|
194
|
+
}
|
|
195
|
+
return kind
|
|
180
196
|
}
|
|
@@ -220,6 +220,33 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
|
|
|
220
220
|
expect(calls).toBe(0)
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
// onDelivered: the deliver-until-acked enrol hook (clerk lost-message
|
|
224
|
+
// incident 2026-06-03). A socket-write "success" is not proof claude
|
|
225
|
+
// consumed it; the caller uses onDelivered to enrol the redelivered inbound
|
|
226
|
+
// in the deliver-until-acked queue so the sweep re-delivers until `enqueue`.
|
|
227
|
+
it('calls onDelivered for each CONFIRMED-delivered group (per merged identity)', () => {
|
|
228
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
229
|
+
buf.push('klanker', inbound('user', 1))
|
|
230
|
+
buf.push('klanker', inbound('cron', 2)) // source-tagged → its own group
|
|
231
|
+
const delivered: number[] = []
|
|
232
|
+
const r = redeliverBufferedInbound(buf, 'klanker', () => true, undefined, (merged) => {
|
|
233
|
+
delivered.push(merged.messageId as number)
|
|
234
|
+
})
|
|
235
|
+
expect(r.redelivered).toBe(2)
|
|
236
|
+
expect(delivered).toEqual([1, 2]) // fired once per group, carrying the merged identity
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('does NOT call onDelivered for a group that failed to send (re-buffered, not enrolled)', () => {
|
|
240
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
241
|
+
buf.push('klanker', inbound('user', 1))
|
|
242
|
+
const delivered: number[] = []
|
|
243
|
+
const r = redeliverBufferedInbound(buf, 'klanker', () => false, undefined, (m) =>
|
|
244
|
+
delivered.push(m.messageId as number),
|
|
245
|
+
)
|
|
246
|
+
expect(r.rebuffered).toBe(1)
|
|
247
|
+
expect(delivered).toEqual([]) // never enrolled — buffer/spool still own it
|
|
248
|
+
})
|
|
249
|
+
|
|
223
250
|
it('only touches the named agent', () => {
|
|
224
251
|
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
225
252
|
buf.push('klanker', inbound('user', 1))
|
|
@@ -179,4 +179,23 @@ describe('selectResumeBuilder', () => {
|
|
|
179
179
|
expect(selectResumeBuilder(endedVia)).toBe(expected)
|
|
180
180
|
})
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
// 3h staleness failsafe (operator spec, 2026-06-03).
|
|
184
|
+
const MAX = 10_800_000 // 3h
|
|
185
|
+
it('downgrades a fresh resume to report when older than maxAgeMs (no auto-resume of stale work)', () => {
|
|
186
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
187
|
+
expect(selectResumeBuilder(null, { ageMs: MAX + 60_000, maxAgeMs: MAX })).toBe('report')
|
|
188
|
+
})
|
|
189
|
+
it('keeps resume when within maxAgeMs', () => {
|
|
190
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX })).toBe('resume')
|
|
191
|
+
expect(selectResumeBuilder('sigterm', { ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
|
|
192
|
+
})
|
|
193
|
+
it('age cap never UPGRADES — report/null stay as-is regardless of age', () => {
|
|
194
|
+
expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
195
|
+
expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
|
|
196
|
+
})
|
|
197
|
+
it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
|
|
198
|
+
expect(selectResumeBuilder('restart')).toBe('resume')
|
|
199
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
|
|
200
|
+
})
|
|
182
201
|
})
|