switchroom 0.18.30 → 0.18.32
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 +4 -2
- package/dist/auth-broker/index.js +4 -2
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +708 -255
- package/dist/host-control/main.js +5 -3
- package/dist/vault/approvals/kernel-server.js +4 -2
- package/dist/vault/broker/server.js +4 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +142 -7
- package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +5479 -7069
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/subagent-watcher.ts +111 -28
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +27 -3
- package/telegram-plugin/worker-activity-feed.ts +24 -0
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-flush pre-delivery suppression decision (S1 fix, fable red-team
|
|
3
|
+
* 2026-07-17 — `~klanker/work/fable-redteam-delivery-20260717/REDTEAM.md`).
|
|
4
|
+
*
|
|
5
|
+
* Before the turn-flush backstop posts the captured terminal answer, it asks:
|
|
6
|
+
* "did a reply already land for this turn in the last ~2s?" (a race guard for
|
|
7
|
+
* a reply whose IPC signal hadn't registered when `decideTurnFlush` ran, and
|
|
8
|
+
* for answer-stream materializations that already delivered the answer text).
|
|
9
|
+
*
|
|
10
|
+
* The OLD predicate — `getRecentOutboundCount(chatId, 2) > 0` — counted ANY
|
|
11
|
+
* assistant row in the WHOLE chat: a background worker's `progress_update`, a
|
|
12
|
+
* command ack / restart notice, or a reply in a DIFFERENT forum topic all
|
|
13
|
+
* suppressed the flush, and the branch then CLOSED the delivery obligation —
|
|
14
|
+
* the user's real answer was dropped with no re-present. This module scopes
|
|
15
|
+
* the predicate to a SUBSTANTIVE (≥ `FINAL_ANSWER_MIN_CHARS`) outbound in the
|
|
16
|
+
* SAME thread, via the injected `hasSubstantiveOutbound` (the gateway wires
|
|
17
|
+
* `hasOutboundDeliveredSince`, whose thread/length semantics are pinned by
|
|
18
|
+
* `history.test.ts`).
|
|
19
|
+
*
|
|
20
|
+
* Residual (documented, accepted): a ≥200-char same-thread non-answer outbound
|
|
21
|
+
* (e.g. an unusually long progress update) inside the 2s window still
|
|
22
|
+
* suppresses — the history schema has no message-kind column to discriminate
|
|
23
|
+
* further. That residual is why the CALLER must NOT close the obligation on
|
|
24
|
+
* suppression: a genuine reply closes its own obligation idempotently, while a
|
|
25
|
+
* false-positive suppression leaves it open for the liveness floor / sweep.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
29
|
+
|
|
30
|
+
/** How far back a just-landed reply can be and still suppress the flush. */
|
|
31
|
+
export const FLUSH_SUPPRESSION_WINDOW_MS = 2000
|
|
32
|
+
|
|
33
|
+
export interface FlushSuppressionDeps {
|
|
34
|
+
/** Durable substantive-outbound oracle — the gateway passes
|
|
35
|
+
* `hasOutboundDeliveredSince` (thread-scoped, length-floored). */
|
|
36
|
+
hasSubstantiveOutbound(
|
|
37
|
+
chatId: string,
|
|
38
|
+
sinceMs: number,
|
|
39
|
+
threadId: number | null,
|
|
40
|
+
minChars: number,
|
|
41
|
+
): boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FlushSuppressionArgs {
|
|
45
|
+
chatId: string
|
|
46
|
+
/** The turn's origin thread. `null` = chat root / DM (matches the
|
|
47
|
+
* `hasOutboundDeliveredSince` explicit-null semantics — never pass
|
|
48
|
+
* `undefined`, which would match ANY thread and re-open the
|
|
49
|
+
* cross-topic false positive this module exists to close). */
|
|
50
|
+
threadId: number | null
|
|
51
|
+
/** Length of the captured answer the flush is about to deliver. The
|
|
52
|
+
* length floor is `min(FINAL_ANSWER_MIN_CHARS, answerLength)`: a row
|
|
53
|
+
* can only suppress the flush if it is at least as long as the answer
|
|
54
|
+
* itself (up to the 200-char substantive cap). A TERSE captured answer
|
|
55
|
+
* ("Yes — done.") is therefore still suppressible by its own short
|
|
56
|
+
* answer-stream materialization (avoiding a duplicate bubble), while a
|
|
57
|
+
* short progress ping can never suppress a LONG composed answer. */
|
|
58
|
+
answerLength: number
|
|
59
|
+
nowMs: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True iff the flush should be suppressed: a substantive same-thread outbound
|
|
64
|
+
* landed within the suppression window. Fails open (no suppression) on oracle
|
|
65
|
+
* errors — delivering a possible duplicate beats dropping the only copy.
|
|
66
|
+
*/
|
|
67
|
+
export function shouldSuppressTurnFlush(
|
|
68
|
+
deps: FlushSuppressionDeps,
|
|
69
|
+
args: FlushSuppressionArgs,
|
|
70
|
+
): boolean {
|
|
71
|
+
const minChars = Math.max(1, Math.min(FINAL_ANSWER_MIN_CHARS, args.answerLength))
|
|
72
|
+
try {
|
|
73
|
+
return deps.hasSubstantiveOutbound(
|
|
74
|
+
args.chatId,
|
|
75
|
+
args.nowMs - FLUSH_SUPPRESSION_WINDOW_MS,
|
|
76
|
+
args.threadId,
|
|
77
|
+
minChars,
|
|
78
|
+
)
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -36,6 +36,25 @@ export interface PendingUserNotice {
|
|
|
36
36
|
kind: string
|
|
37
37
|
/** When the notice was scheduled (ms epoch). */
|
|
38
38
|
atMs: number
|
|
39
|
+
/**
|
|
40
|
+
* TOPIC KEY (#3294) — `statusKey(chatId, threadId)` of the turn that was live
|
|
41
|
+
* when the error surfaced, or `undefined` when no turn was attributable (the
|
|
42
|
+
* error arrived between turns, or after a silence poke nulled the live turn).
|
|
43
|
+
*
|
|
44
|
+
* Why it exists: the api_error operator event is agent-level (its wire
|
|
45
|
+
* `chatId` is always empty), so the gate USED to collapse per-agent and let
|
|
46
|
+
* ANY turn end resolve the notice. Under the PR-4e keyed-liveness flag
|
|
47
|
+
* (concurrent per-topic turns) that is a bug: turn B's reply-less end would
|
|
48
|
+
* flush turn A's pending notice while A may still recover — a FALSE "couldn't
|
|
49
|
+
* complete" claim. Keying the notice by the live turn's topic means only a
|
|
50
|
+
* turn end on the SAME topic resolves it (see {@link
|
|
51
|
+
* PendingUserNoticeGate.resolveTurnEnd}).
|
|
52
|
+
*
|
|
53
|
+
* A notice with an UNDEFINED key keeps the legacy agent-wide resolution (any
|
|
54
|
+
* turn end resolves it), so single-turn gateways — the current norm, where
|
|
55
|
+
* there is only ever one topic — are byte-identical to the pre-#3294 gate.
|
|
56
|
+
*/
|
|
57
|
+
key?: string
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
/** How long a scheduled notice may wait for a resolving turn end. */
|
|
@@ -45,28 +64,55 @@ export class PendingUserNoticeGate {
|
|
|
45
64
|
private pending: PendingUserNotice[] = []
|
|
46
65
|
|
|
47
66
|
/**
|
|
48
|
-
* Schedule a notice for turn-end resolution. Collapses per agent
|
|
49
|
-
* of error lines within one turn holds ONE pending notice
|
|
67
|
+
* Schedule a notice for turn-end resolution. Collapses per (agent, topic key)
|
|
68
|
+
* — a burst of error lines within one turn holds ONE pending notice for that
|
|
69
|
+
* topic, not a stack. Distinct concurrent topics (keyed liveness) each keep
|
|
70
|
+
* their own pending notice, so one topic's burst never overwrites another's.
|
|
71
|
+
* (For a single-turn gateway every notice shares the one topic — or an
|
|
72
|
+
* undefined key — so the collapse is identical to the pre-#3294 per-agent one.)
|
|
50
73
|
*/
|
|
51
74
|
schedule(notice: PendingUserNotice): void {
|
|
52
75
|
this.prune(notice.atMs)
|
|
53
|
-
this.pending = this.pending.filter(
|
|
76
|
+
this.pending = this.pending.filter(
|
|
77
|
+
(p) => !(p.agent === notice.agent && p.key === notice.key),
|
|
78
|
+
)
|
|
54
79
|
this.pending.push(notice)
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/**
|
|
58
|
-
* Resolve at
|
|
59
|
-
* (the gateway passes `finalAnswerDelivered ||
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
83
|
+
* Resolve at the end of the turn whose topic is `turnKey`. `turnDeliveredReply`
|
|
84
|
+
* is the turn's outcome signal (the gateway passes `finalAnswerDelivered ||
|
|
85
|
+
* replyCalled`).
|
|
86
|
+
*
|
|
87
|
+
* A pending notice is RESOLVED by this turn end iff it belongs to the ending
|
|
88
|
+
* turn's topic (`p.key === turnKey`) OR it is unattributed (`p.key ===
|
|
89
|
+
* undefined` — legacy agent-wide resolution, kept so single-turn gateways are
|
|
90
|
+
* unchanged). Notices scheduled for a DIFFERENT live topic stay pending until
|
|
91
|
+
* their own topic's turn ends (or the TTL expires) — this is the #3294 fix:
|
|
92
|
+
* turn B's end no longer flushes turn A's pending notice.
|
|
93
|
+
*
|
|
94
|
+
* Of the resolved notices:
|
|
95
|
+
* - `turnDeliveredReply === true` → the turn recovered; the resolved
|
|
96
|
+
* notices are dropped and `[]` is returned.
|
|
97
|
+
* - `turnDeliveredReply === false` → the turn died reply-less; the resolved,
|
|
98
|
+
* un-expired notices are returned EXACTLY ONCE for the caller to send.
|
|
99
|
+
* Either way the resolved notices leave the ledger (a notice never survives
|
|
100
|
+
* the end of the turn it is keyed to).
|
|
64
101
|
*/
|
|
65
|
-
resolveTurnEnd(
|
|
102
|
+
resolveTurnEnd(
|
|
103
|
+
turnKey: string | undefined,
|
|
104
|
+
turnDeliveredReply: boolean,
|
|
105
|
+
now: number = Date.now(),
|
|
106
|
+
): PendingUserNotice[] {
|
|
66
107
|
this.prune(now)
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
const resolved: PendingUserNotice[] = []
|
|
109
|
+
const remaining: PendingUserNotice[] = []
|
|
110
|
+
for (const p of this.pending) {
|
|
111
|
+
if (p.key === turnKey || p.key === undefined) resolved.push(p)
|
|
112
|
+
else remaining.push(p)
|
|
113
|
+
}
|
|
114
|
+
this.pending = remaining
|
|
115
|
+
return turnDeliveredReply ? [] : resolved
|
|
70
116
|
}
|
|
71
117
|
|
|
72
118
|
/** True when at least one un-expired notice is pending (does not mutate). */
|
|
@@ -855,13 +855,14 @@ const BACKFILL_RETRY_INTERVAL_MS = 3000
|
|
|
855
855
|
|
|
856
856
|
/**
|
|
857
857
|
* Fix #9a: cap on `terminatedAgentIds` (the re-discovery dedup guard in
|
|
858
|
-
* `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the
|
|
858
|
+
* `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the map only grew
|
|
859
859
|
* (added on every terminal cleanup, only ever cleared wholesale in
|
|
860
860
|
* `stop()`), so a long-lived gateway with sustained sub-agent throughput
|
|
861
|
-
* accumulated ids without bound. A `
|
|
861
|
+
* accumulated ids without bound. A `Map` preserves insertion order, so once
|
|
862
862
|
* the cap is hit the OLDEST id is evicted on each new insert (simple ring
|
|
863
863
|
* buffer semantics) — recently-terminated ids (the ones actually at risk of
|
|
864
|
-
* a re-discovery race) always stay
|
|
864
|
+
* a re-discovery race, or of a SendMessage resume — issue #3315) always stay
|
|
865
|
+
* covered.
|
|
865
866
|
*/
|
|
866
867
|
const TERMINATED_AGENT_IDS_CAP = 5000
|
|
867
868
|
|
|
@@ -1861,18 +1862,26 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1861
1862
|
const historicalFiles = new Set<string>()
|
|
1862
1863
|
/**
|
|
1863
1864
|
* AgentIds that have transitioned to a terminal state and been swept
|
|
1864
|
-
* out of `registry` by `cleanupTerminalAgent
|
|
1865
|
-
*
|
|
1866
|
-
* the file on disk after the sub-agent
|
|
1867
|
-
* the next `rescanSubagentDirs` poll
|
|
1868
|
-
* `registerAgent`, the fresh entry read the
|
|
1869
|
-
* line, and `maybySendStateTransition` fired a
|
|
1870
|
-
* notification — looping forever every grace-window.
|
|
1865
|
+
* out of `registry` by `cleanupTerminalAgent`, mapped to the JSONL byte
|
|
1866
|
+
* SIZE at cleanup time. Issue #1116 (Bug B): the JSONL file outlives the
|
|
1867
|
+
* registry entry — Claude Code leaves the file on disk after the sub-agent
|
|
1868
|
+
* finishes. Without this guard, the next `rescanSubagentDirs` poll
|
|
1869
|
+
* re-discovered the file, called `registerAgent`, the fresh entry read the
|
|
1870
|
+
* terminal `turn_duration` line, and `maybySendStateTransition` fired a
|
|
1871
|
+
* duplicate "Worker done" notification — looping forever every grace-window.
|
|
1871
1872
|
*
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1873
|
+
* Issue #3315 (SendMessage resume): a resumed sub-agent reuses the SAME
|
|
1874
|
+
* `agent-<id>.jsonl`, appending a NEW turn to it — so the file's size grows
|
|
1875
|
+
* PAST the size it had at terminal cleanup. `scanSubagentsDir` compares the
|
|
1876
|
+
* live file size against this recorded terminal size: at/below it the JSONL
|
|
1877
|
+
* is a static leftover and stays suppressed (#1116 Bug B); above it the
|
|
1878
|
+
* worker resumed and is re-registered live (#3315) so its progress card
|
|
1879
|
+
* updates again. Storing the size (not a bare membership flag) is what makes
|
|
1880
|
+
* that distinction deterministic and lifecycle-driven rather than a
|
|
1881
|
+
* heuristic. Map iteration order is insertion order, so the eviction ring
|
|
1882
|
+
* buffer (Fix #9a) still evicts the oldest id.
|
|
1874
1883
|
*/
|
|
1875
|
-
const terminatedAgentIds = new
|
|
1884
|
+
const terminatedAgentIds = new Map<string, number>()
|
|
1876
1885
|
/**
|
|
1877
1886
|
* Issue #3023 (card resurrection). Per-worker record of a FALSE terminal
|
|
1878
1887
|
* finish — a terminal state produced by silent-stall synthesis (NOT a real
|
|
@@ -1916,7 +1925,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1916
1925
|
|
|
1917
1926
|
// ─── Per-agent registration ─────────────────────────────────────────────
|
|
1918
1927
|
|
|
1919
|
-
|
|
1928
|
+
/**
|
|
1929
|
+
* @param resumeFromCursor Issue #3315: when re-registering a worker whose
|
|
1930
|
+
* JSONL resumed growing after a terminal cleanup (a SendMessage resume),
|
|
1931
|
+
* start the tail cursor at the terminal byte boundary instead of 0. The
|
|
1932
|
+
* already-handed-back completed turn (its `sub_agent_turn_end` and all
|
|
1933
|
+
* prior events) is therefore never re-read — so the fresh entry stays
|
|
1934
|
+
* `running` on the NEW turn's content rather than instantly re-reading the
|
|
1935
|
+
* old terminal line and re-finalising. Omitted (⇒ 0) for every normal
|
|
1936
|
+
* registration, which reads from the start.
|
|
1937
|
+
*/
|
|
1938
|
+
function registerAgent(filePath: string, agentId: string, resumeFromCursor?: number): void {
|
|
1920
1939
|
if (registry.has(agentId)) return
|
|
1921
1940
|
const n = nowFn()
|
|
1922
1941
|
const isHistorical = historicalFiles.has(filePath)
|
|
@@ -1976,7 +1995,12 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1976
1995
|
}
|
|
1977
1996
|
|
|
1978
1997
|
const tail: SubTail = {
|
|
1979
|
-
|
|
1998
|
+
// Normally read from the start (to replay the full transcript); on a
|
|
1999
|
+
// resume re-registration (#3315) start at the terminal boundary so only
|
|
2000
|
+
// the new turn is read. A resumed cursor sits on a JSONL newline boundary
|
|
2001
|
+
// (the prior turn ended with a complete `turn_end` line), so no partial
|
|
2002
|
+
// line is straddled.
|
|
2003
|
+
cursor: resumeFromCursor ?? 0,
|
|
1980
2004
|
pendingPartial: '',
|
|
1981
2005
|
hasEmittedStart: false,
|
|
1982
2006
|
watcher: null,
|
|
@@ -2274,24 +2298,42 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2274
2298
|
try { tail.watcher.close() } catch { /* ignore */ }
|
|
2275
2299
|
tail.watcher = null
|
|
2276
2300
|
}
|
|
2277
|
-
tails.delete(agentId)
|
|
2278
2301
|
const entry = registry.get(agentId)
|
|
2302
|
+
// Issue #3315: snapshot the JSONL byte size at cleanup so a later
|
|
2303
|
+
// SendMessage-resume — which appends a NEW turn to the SAME
|
|
2304
|
+
// agent-<id>.jsonl — is detected by `scanSubagentsDir` as growth PAST this
|
|
2305
|
+
// size and re-registered. The on-disk stat is the source of truth (it
|
|
2306
|
+
// captures any bytes that landed during the terminal-cleanup grace window,
|
|
2307
|
+
// so only genuinely post-cleanup writes trip a resume); fall back to the
|
|
2308
|
+
// tail cursor, then 0, when the file is unreadable.
|
|
2309
|
+
let terminalSize = tail?.cursor ?? 0
|
|
2310
|
+
if (entry?.filePath) {
|
|
2311
|
+
try {
|
|
2312
|
+
terminalSize = fs.statSync(entry.filePath).size
|
|
2313
|
+
} catch { /* keep the cursor-based fallback */ }
|
|
2314
|
+
}
|
|
2315
|
+
tails.delete(agentId)
|
|
2279
2316
|
if (entry?.filePath) {
|
|
2280
2317
|
knownFiles.delete(entry.filePath)
|
|
2281
2318
|
}
|
|
2282
2319
|
registry.delete(agentId)
|
|
2283
2320
|
// Issue #1116 (Bug B): record that this agent has been fully
|
|
2284
|
-
// processed so a rescan that rediscovers the still-present
|
|
2285
|
-
// doesn't re-register and re-notify.
|
|
2321
|
+
// processed so a rescan that rediscovers the still-present (unchanged)
|
|
2322
|
+
// JSONL doesn't re-register and re-notify. The recorded value is the
|
|
2323
|
+
// terminal byte size (issue #3315): `scanSubagentsDir` keeps suppressing
|
|
2324
|
+
// while the file stays at/below it, and re-registers once it grows past it
|
|
2325
|
+
// (a resume).
|
|
2286
2326
|
//
|
|
2287
|
-
// Fix #9a: bound the
|
|
2288
|
-
// gateway — evict the oldest id once the cap is hit (
|
|
2289
|
-
// order is insertion order, so
|
|
2327
|
+
// Fix #9a: bound the Map so it can't grow unboundedly over a long-lived
|
|
2328
|
+
// gateway — evict the oldest id once the cap is hit (Map iteration
|
|
2329
|
+
// order is insertion order, so the first key is the oldest). A re-set of
|
|
2330
|
+
// an existing key updates its value without moving it, so a re-completed
|
|
2331
|
+
// (resumed-then-finished-again) agent just refreshes its terminal size.
|
|
2290
2332
|
if (!terminatedAgentIds.has(agentId) && terminatedAgentIds.size >= terminatedAgentIdsCap) {
|
|
2291
|
-
const oldest = terminatedAgentIds.
|
|
2333
|
+
const oldest = terminatedAgentIds.keys().next().value
|
|
2292
2334
|
if (oldest != null) terminatedAgentIds.delete(oldest)
|
|
2293
2335
|
}
|
|
2294
|
-
terminatedAgentIds.
|
|
2336
|
+
terminatedAgentIds.set(agentId, terminalSize)
|
|
2295
2337
|
log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
|
|
2296
2338
|
// Authoritative terminal sweep → notify the worker-activity feed so its row
|
|
2297
2339
|
// for this agent is removed even on the paths that never fire `onFinish`
|
|
@@ -2865,11 +2907,52 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2865
2907
|
const filePath = join(subagentsPath, e)
|
|
2866
2908
|
if (knownFiles.has(filePath)) continue
|
|
2867
2909
|
const agentId = e.slice('agent-'.length, -'.jsonl'.length)
|
|
2868
|
-
// Issue #1116 (Bug B)
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
|
|
2910
|
+
// Issue #1116 (Bug B) + issue #3315 (SendMessage resume). A rediscovered
|
|
2911
|
+
// JSONL whose agent already went terminal + was swept is normally a
|
|
2912
|
+
// static leftover Claude Code left on disk — re-registering it would
|
|
2913
|
+
// re-read the terminal turn and re-fire a duplicate handback, looping
|
|
2914
|
+
// every grace-window (#1116). BUT a SendMessage resume appends a NEW turn
|
|
2915
|
+
// to the SAME agent-<id>.jsonl, growing the file PAST the size it had at
|
|
2916
|
+
// terminal cleanup. That growth is the deterministic, lifecycle-driven
|
|
2917
|
+
// signal (not a heuristic) that the worker resumed and must be tracked
|
|
2918
|
+
// again so its progress card resumes updating. Distinguish the two by the
|
|
2919
|
+
// recorded terminal size.
|
|
2920
|
+
const terminalSize = terminatedAgentIds.get(agentId)
|
|
2921
|
+
if (terminalSize !== undefined) {
|
|
2922
|
+
// A worker whose terminal state was a FALSE finish (silent-stall
|
|
2923
|
+
// synthesis) is owned by the issue-#3023 resurrection path
|
|
2924
|
+
// (`checkResurrections`), which carries the bounded-chain +
|
|
2925
|
+
// `onResurrect`/`onWorkerLost` semantics and re-reads from cursor 0 to
|
|
2926
|
+
// rebuild the still-in-progress turn. Skipping it here avoids
|
|
2927
|
+
// double-handling the same growth in one poll; this branch's concern is
|
|
2928
|
+
// GENUINE terminal completions (real `turn_end`, no false-finish record)
|
|
2929
|
+
// that were resumed via SendMessage (issue #3315). A false finish that
|
|
2930
|
+
// has since been evicted from `falseFinishTracker` (512-cap) is no
|
|
2931
|
+
// longer double-handled, so falling through to re-register it live here
|
|
2932
|
+
// is correct.
|
|
2933
|
+
if (falseFinishTracker.has(agentId)) continue
|
|
2934
|
+
let currentSize: number
|
|
2935
|
+
try {
|
|
2936
|
+
currentSize = fs.statSync(filePath).size
|
|
2937
|
+
} catch {
|
|
2938
|
+
continue // unreadable — nothing to resume; keep suppressing
|
|
2939
|
+
}
|
|
2940
|
+
// At/below the terminal snapshot ⇒ static leftover file (#1116 Bug B):
|
|
2941
|
+
// stay suppressed, do NOT re-register.
|
|
2942
|
+
if (currentSize <= terminalSize) continue
|
|
2943
|
+
// Grew past it ⇒ the worker resumed (#3315). Re-register as a fresh
|
|
2944
|
+
// LIVE worker, reading only from the terminal boundary so the
|
|
2945
|
+
// already-completed turn is not replayed. At-most-once per resume:
|
|
2946
|
+
// dropping the id from terminatedAgentIds means the next completion
|
|
2947
|
+
// re-adds it with the new (larger) terminal size, so one resume yields
|
|
2948
|
+
// exactly one re-registration.
|
|
2949
|
+
log?.(`subagent-watcher: agent ${agentId} resumed after terminal cleanup — JSONL grew ${terminalSize} → ${currentSize} bytes (SendMessage resume); re-registering as live so its progress card resumes (issue #3315)`)
|
|
2950
|
+
terminatedAgentIds.delete(agentId)
|
|
2951
|
+
historicalFiles.delete(filePath)
|
|
2952
|
+
knownFiles.add(filePath)
|
|
2953
|
+
registerAgent(filePath, agentId, terminalSize)
|
|
2954
|
+
continue
|
|
2955
|
+
}
|
|
2873
2956
|
knownFiles.add(filePath)
|
|
2874
2957
|
// During the initial boot scan, mark every discovered file as
|
|
2875
2958
|
// historical so stall-detection and completion notifications are
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
backstopReceiptIds,
|
|
6
6
|
backstopDelivered,
|
|
7
7
|
runBackstopDelivery,
|
|
8
|
+
combineReadBackResults,
|
|
9
|
+
type ReadBackResult,
|
|
8
10
|
} from '../gateway/backstop-delivery.js'
|
|
9
11
|
import {
|
|
10
12
|
backstopSendOutcomeGated,
|
|
@@ -248,3 +250,168 @@ describe('runBackstopDelivery — integration oracle over the delivery wiring',
|
|
|
248
250
|
expect(recorded[0].ids).toHaveLength(recorded[0].texts.length)
|
|
249
251
|
})
|
|
250
252
|
})
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* #3278 — read-back confirmation of an accepted-but-dropped send.
|
|
256
|
+
*
|
|
257
|
+
* A returned message_id proves Telegram ACCEPTED the send, not that the message
|
|
258
|
+
* is VISIBLE (a flood/anti-spam silent discard returns a fresh id yet the user
|
|
259
|
+
* sees nothing). These assert the deterministic OUTCOMES of the per-chunk state
|
|
260
|
+
* machine `unsent → pending → landed-unconfirmed → {landed-confirmed | unsent}`
|
|
261
|
+
* driven by a no-op editMessageText read-back — NOT code-path execution.
|
|
262
|
+
*/
|
|
263
|
+
describe('#3278 read-back — the ledger confirmation state machine', () => {
|
|
264
|
+
it('confirmChunk transitions landed-unconfirmed → landed-confirmed', () => {
|
|
265
|
+
const l = new BackstopDeliveryLedger()
|
|
266
|
+
l.recordChunk('#t', 0, [900]) // landed-unconfirmed
|
|
267
|
+
expect(l.hasChunk('#t', 0)).toBe(true)
|
|
268
|
+
expect(l.hasConfirmedChunk('#t', 0)).toBe(false)
|
|
269
|
+
expect(l.landedUnconfirmedIndices('#t', 1)).toEqual([0])
|
|
270
|
+
l.confirmChunk('#t', 0)
|
|
271
|
+
expect(l.hasConfirmedChunk('#t', 0)).toBe(true)
|
|
272
|
+
expect(l.landedUnconfirmedIndices('#t', 1)).toEqual([])
|
|
273
|
+
expect(l.allConfirmed('#t', 1)).toBe(true)
|
|
274
|
+
expect(l.confirmedIds('#t')).toEqual([900])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('demoteChunk resets a landed(-unconfirmed) chunk back to unsent (safe re-send)', () => {
|
|
278
|
+
const l = new BackstopDeliveryLedger()
|
|
279
|
+
l.recordChunk('#t', 0, [901])
|
|
280
|
+
l.confirmChunk('#t', 0)
|
|
281
|
+
l.demoteChunk('#t', 0) // positive-absence read-back
|
|
282
|
+
expect(l.hasChunk('#t', 0)).toBe(false)
|
|
283
|
+
expect(l.hasConfirmedChunk('#t', 0)).toBe(false)
|
|
284
|
+
expect(l.unsentIndices('#t', 1)).toEqual([0]) // resume set includes it again
|
|
285
|
+
expect(l.confirmedIds('#t')).toEqual([])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('allConfirmed is false while any chunk is only landed-unconfirmed', () => {
|
|
289
|
+
const l = new BackstopDeliveryLedger()
|
|
290
|
+
l.recordChunk('#t', 0, [1]); l.confirmChunk('#t', 0)
|
|
291
|
+
l.recordChunk('#t', 1, [2]) // landed-unconfirmed
|
|
292
|
+
expect(l.allConfirmed('#t', 2)).toBe(false)
|
|
293
|
+
expect(l.landedUnconfirmedIndices('#t', 2)).toEqual([1])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('confirmedIds returns ONLY confirmed chunk ids, in index order', () => {
|
|
297
|
+
const l = new BackstopDeliveryLedger()
|
|
298
|
+
l.recordChunk('#t', 0, [10]); l.confirmChunk('#t', 0)
|
|
299
|
+
l.recordChunk('#t', 1, [11]) // unconfirmed
|
|
300
|
+
l.recordChunk('#t', 2, [12, 13]); l.confirmChunk('#t', 2)
|
|
301
|
+
expect(l.confirmedIds('#t')).toEqual([10, 12, 13])
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('#3278 combineReadBackResults — per-chunk id combine (guard A7)', () => {
|
|
306
|
+
it('ANY absent id ⇒ absent (re-send the whole chunk)', () => {
|
|
307
|
+
expect(combineReadBackResults(['exists', 'absent'])).toBe('absent')
|
|
308
|
+
expect(combineReadBackResults(['absent', 'ambiguous'])).toBe('absent')
|
|
309
|
+
})
|
|
310
|
+
it('ALL exist ⇒ exists', () => {
|
|
311
|
+
expect(combineReadBackResults(['exists', 'exists'])).toBe('exists')
|
|
312
|
+
})
|
|
313
|
+
it('some ambiguous, none absent ⇒ ambiguous (never re-send)', () => {
|
|
314
|
+
expect(combineReadBackResults(['exists', 'ambiguous'])).toBe('ambiguous')
|
|
315
|
+
})
|
|
316
|
+
it('empty id set ⇒ ambiguous (fabricate neither confirm nor demote)', () => {
|
|
317
|
+
expect(combineReadBackResults([])).toBe('ambiguous')
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('#3278 runBackstopDelivery — read-back drives the delivery outcome', () => {
|
|
322
|
+
it('exists probe ⇒ landed-confirmed, delivered=true, ONE probe, no re-send', async () => {
|
|
323
|
+
const ledger = new BackstopDeliveryLedger()
|
|
324
|
+
const sendChunk = vi.fn(async () => [960])
|
|
325
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'exists')
|
|
326
|
+
const res = await runBackstopDelivery(ledger, '#ok', ['the answer'], null, { sendChunk, readBack }, 3)
|
|
327
|
+
expect(res.delivered).toBe(true)
|
|
328
|
+
expect(res.exhausted).toBe(false)
|
|
329
|
+
expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent
|
|
330
|
+
expect(readBack).toHaveBeenCalledTimes(1) // exactly one probe per chunk
|
|
331
|
+
expect(ledger.hasConfirmedChunk('#ok', 0)).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('400/absent probe ⇒ demote → RE-SENT EXACTLY ONCE, then confirmed', async () => {
|
|
335
|
+
const ledger = new BackstopDeliveryLedger()
|
|
336
|
+
let sends = 0
|
|
337
|
+
const sendChunk = vi.fn(async () => { sends++; return [900 + sends] })
|
|
338
|
+
let probes = 0
|
|
339
|
+
// First probe: the send was silently dropped (absent). After the re-send the
|
|
340
|
+
// message is present (exists).
|
|
341
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => {
|
|
342
|
+
probes++
|
|
343
|
+
return probes === 1 ? 'absent' : 'exists'
|
|
344
|
+
})
|
|
345
|
+
const res = await runBackstopDelivery(ledger, '#drop', ['answer'], null, { sendChunk, readBack }, 3)
|
|
346
|
+
expect(sendChunk).toHaveBeenCalledTimes(2) // initial + exactly one re-send
|
|
347
|
+
expect(res.delivered).toBe(true)
|
|
348
|
+
expect(ledger.hasConfirmedChunk('#drop', 0)).toBe(true)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('429/ambiguous probe ⇒ NOT re-sent, stays landed-unconfirmed, delivered=false', async () => {
|
|
352
|
+
const ledger = new BackstopDeliveryLedger()
|
|
353
|
+
const sendChunk = vi.fn(async () => [950])
|
|
354
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'ambiguous')
|
|
355
|
+
const res = await runBackstopDelivery(ledger, '#amb', ['answer'], null, { sendChunk, readBack }, 3)
|
|
356
|
+
expect(sendChunk).toHaveBeenCalledTimes(1) // never re-sent on ambiguous
|
|
357
|
+
expect(res.delivered).toBe(false)
|
|
358
|
+
expect(res.exhausted).toBe(true)
|
|
359
|
+
expect(ledger.hasConfirmedChunk('#amb', 0)).toBe(false)
|
|
360
|
+
expect(ledger.landedUnconfirmedIndices('#amb', 1)).toEqual([0]) // still landed-unconfirmed
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('a read-back adapter THAT THROWS is treated as ambiguous — never re-sent', async () => {
|
|
364
|
+
const ledger = new BackstopDeliveryLedger()
|
|
365
|
+
const sendChunk = vi.fn(async () => [951])
|
|
366
|
+
const readBack = vi.fn(async () => { throw new Error('boom') })
|
|
367
|
+
const res = await runBackstopDelivery(ledger, '#throw', ['answer'], null, { sendChunk, readBack }, 3)
|
|
368
|
+
expect(sendChunk).toHaveBeenCalledTimes(1)
|
|
369
|
+
expect(res.delivered).toBe(false)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('#3278 CORE: API-ack fresh id but read-back ABSENT ⇒ send_failed, NOT complete', async () => {
|
|
373
|
+
const ledger = new BackstopDeliveryLedger()
|
|
374
|
+
const cardId = 500
|
|
375
|
+
// The Bot API returns a fresh non-card id (an accept) every time...
|
|
376
|
+
const sendChunk = vi.fn(async () => [970])
|
|
377
|
+
// ...but the read-back proves the message is not actually in the chat
|
|
378
|
+
// (flood/anti-spam silent discard). It never confirms.
|
|
379
|
+
const readBack = vi.fn(async (): Promise<ReadBackResult> => 'absent')
|
|
380
|
+
const res = await runBackstopDelivery(ledger, '#ghost', ['answer'], cardId, { sendChunk, readBack }, 3)
|
|
381
|
+
expect(res.delivered).toBe(false) // pre-#3278 this was true → false `complete`
|
|
382
|
+
expect(res.exhausted).toBe(true)
|
|
383
|
+
// The exact input the gateway feeds the turn record: delivered=false ⇒ the
|
|
384
|
+
// status is send_failed and the obligation is left OPEN — the answer is NOT
|
|
385
|
+
// silently marked complete.
|
|
386
|
+
const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
|
|
387
|
+
finalAnswerDelivered: true,
|
|
388
|
+
}
|
|
389
|
+
finalizeBackstopSendGated(turn, {
|
|
390
|
+
threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: cardId,
|
|
391
|
+
})
|
|
392
|
+
expect(computeTurnStatus(turn)).toBe('send_failed')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('scope guard: NO readBack dep ⇒ ZERO probes, legacy confirm-on-landing preserved', async () => {
|
|
396
|
+
const ledger = new BackstopDeliveryLedger()
|
|
397
|
+
const readBack = vi.fn()
|
|
398
|
+
const sendChunk = vi.fn(async () => [980])
|
|
399
|
+
// deliberately omit `readBack` — the hot path / opt-out shape.
|
|
400
|
+
const res = await runBackstopDelivery(ledger, '#noprobe', ['answer'], null, { sendChunk })
|
|
401
|
+
expect(readBack).not.toHaveBeenCalled()
|
|
402
|
+
expect(res.delivered).toBe(true) // confirmed on landing (receipt-gated)
|
|
403
|
+
expect(ledger.hasConfirmedChunk('#noprobe', 0)).toBe(true)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('partial: chunk 0 confirmed, chunk 1 ambiguous ⇒ delivered=false, chunk 0 not re-sent', async () => {
|
|
407
|
+
const ledger = new BackstopDeliveryLedger()
|
|
408
|
+
const calls: number[] = []
|
|
409
|
+
const sendChunk = vi.fn(async (i: number) => { calls.push(i); return [700 + i] })
|
|
410
|
+
const readBack = vi.fn(async (i: number): Promise<ReadBackResult> => (i === 0 ? 'exists' : 'ambiguous'))
|
|
411
|
+
const res = await runBackstopDelivery(ledger, '#part', ['c0', 'c1'], null, { sendChunk, readBack }, 3)
|
|
412
|
+
expect(res.delivered).toBe(false)
|
|
413
|
+
expect(calls.filter(i => i === 0)).toHaveLength(1) // confirmed chunk never re-sent
|
|
414
|
+
expect(ledger.hasConfirmedChunk('#part', 0)).toBe(true)
|
|
415
|
+
expect(ledger.hasConfirmedChunk('#part', 1)).toBe(false)
|
|
416
|
+
})
|
|
417
|
+
})
|