switchroom 0.19.44 → 0.19.46
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 +848 -826
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/dist/gateway/gateway.js +499 -322
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +67 -66
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +58 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* #1177/#1182/#1201 double-sent).
|
|
19
19
|
* - CASE B — a background handback attributed to that ended turn. A
|
|
20
20
|
* `subagent_handback` WAS enqueued after the turn ended and within the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* answer.
|
|
21
|
+
* handback recency window (`HANDBACK_RECENCY_WINDOW_MS` below, #4174), so
|
|
22
|
+
* the late reply might BE it → keep the #3429 content gate and send fresh
|
|
23
|
+
* (two messages), never silently edit/delete the flushed answer.
|
|
24
24
|
*
|
|
25
25
|
* ## Why thread-keyed (F2, dup-audit 2026-07-21)
|
|
26
26
|
*
|
|
@@ -65,12 +65,30 @@
|
|
|
65
65
|
* reply with NO live gateway turn of its own (a background worker completion),
|
|
66
66
|
* so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
|
|
67
67
|
* tier. It is the F1 vector.
|
|
68
|
-
* - Every OTHER synthesized source
|
|
69
|
-
* as its OWN live inbound turn, so
|
|
70
|
-
*
|
|
71
|
-
* record (`decideSupersede`
|
|
72
|
-
* must NOT stamp — stamping
|
|
73
|
-
*
|
|
68
|
+
* - Every OTHER synthesized source that traverses THIS chokepoint (resume_*,
|
|
69
|
+
* reaction, vault_*, Tier-2 cron, …) lands as its OWN live inbound turn, so
|
|
70
|
+
* its reply resolves the `live` tier for its own turnId and structurally
|
|
71
|
+
* cannot supersede a different ended turn's flush record (`decideSupersede`
|
|
72
|
+
* requires `record.turnId === liveTurnId`). Those must NOT stamp — stamping
|
|
73
|
+
* them would needlessly hold the content gate open for an unrelated
|
|
74
|
+
* own-reply in the window (a safe but avoidable visible dup).
|
|
75
|
+
*
|
|
76
|
+
* ## What this chokepoint CANNOT see (#4172 — the caller-identity gate)
|
|
77
|
+
*
|
|
78
|
+
* The invariant above only covers sources delivered through
|
|
79
|
+
* `pendingInboundBuffer.push`. A Tier-1 CHEAP-CRON fire is delivered via
|
|
80
|
+
* `sendToAgent` to the derived `<agent>-cron` bridge and never traverses the
|
|
81
|
+
* buffer — AND its session events are dropped for the cron identity in
|
|
82
|
+
* `onSessionEvent`, so it never mints a live gateway turn either. Its `reply`
|
|
83
|
+
* therefore arrives decoupled (turn == null) with foreign content and NO
|
|
84
|
+
* marker — exactly the shape marker-absence was claimed to disprove. The same
|
|
85
|
+
* holds for any reply arriving on a connection that is not the main agent
|
|
86
|
+
* bridge. That class is closed at a DIFFERENT chokepoint: the reply send path
|
|
87
|
+
* (`outbound-send-path.ts`) refuses supersede/bypass/latch authority to any
|
|
88
|
+
* caller that is not the main agent bridge (`replyCallerIsForeignSession`,
|
|
89
|
+
* cron-session.ts) — identity, not marker stamping, is the deterministic
|
|
90
|
+
* signal there. Setting `cron: { decoupledCompletion: true }` below would NOT
|
|
91
|
+
* work: a Tier-1 fire never reaches this registry's write site.
|
|
74
92
|
*
|
|
75
93
|
* This predicate is therefore the SINGLE point of extension: any future feature
|
|
76
94
|
* that synthesizes an inbound which can land as a DECOUPLED late reply (no live
|
|
@@ -114,6 +132,12 @@ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion
|
|
|
114
132
|
// Everything below lands as its OWN live inbound turn (live tier), so its reply
|
|
115
133
|
// resolves the live tier for its own turnId and structurally cannot supersede a
|
|
116
134
|
// different ended turn's record → not a decoupled-completion vector, must not stamp.
|
|
135
|
+
//
|
|
136
|
+
// cron: `false` covers the TIER-2 (main-bridge, `context:'agent'`) fire, which
|
|
137
|
+
// does land as its own live turn through this chokepoint. The TIER-1 cheap-cron
|
|
138
|
+
// fire never traverses this chokepoint AT ALL (see "What this chokepoint
|
|
139
|
+
// CANNOT see" above) — it is closed by the caller-identity gate in the reply
|
|
140
|
+
// send path (#4172), and flipping this to `true` would not reach it.
|
|
117
141
|
cron: { decoupledCompletion: false },
|
|
118
142
|
reaction: { decoupledCompletion: false },
|
|
119
143
|
subagent_progress: { decoupledCompletion: false },
|
|
@@ -168,6 +192,22 @@ export function stampsHandbackMarker(source: string | null | undefined): boolean
|
|
|
168
192
|
return known.decoupledCompletion
|
|
169
193
|
}
|
|
170
194
|
|
|
195
|
+
/**
|
|
196
|
+
* #4174 — how long after a `subagent_handback` enqueue the content gate stays
|
|
197
|
+
* held for late replies attributed to an ended turn (`handbackCouldOwnReply`,
|
|
198
|
+
* outbound-send-path.ts). This deliberately does NOT follow the supersede
|
|
199
|
+
* window's bounds (#4173): the gate-hold is CHAT-WIDE (the latest-ended owner
|
|
200
|
+
* tier is chat-wide, so the read must be too — MUST-FIX 2 above), which means
|
|
201
|
+
* every in-window handback re-opens the reworded-own-answer visible-duplicate
|
|
202
|
+
* class for EVERY topic in the chat for the window's length. 60 s — the
|
|
203
|
+
* originally shipped bound — covers the enqueue→decoupled-reply gap the marker
|
|
204
|
+
* exists for, while keeping the chat-wide dup exposure per handback small. In
|
|
205
|
+
* a delegation-heavy agent, tying this to a minutes-long supersede bound would
|
|
206
|
+
* hold the gate almost continuously — re-opening the exact duplicate class the
|
|
207
|
+
* supersede machinery closes.
|
|
208
|
+
*/
|
|
209
|
+
export const HANDBACK_RECENCY_WINDOW_MS = 60_000
|
|
210
|
+
|
|
171
211
|
/** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
|
|
172
212
|
const MAIN_THREAD_KEY = '<main>'
|
|
173
213
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4176 — sub-agent reply authority: the discriminator the "serial session"
|
|
3
|
+
* premise was missing.
|
|
4
|
+
*
|
|
5
|
+
* ## The premise that was false
|
|
6
|
+
*
|
|
7
|
+
* The turn-completion window (#4173, `flushed-turn-supersede.ts`) rests on this
|
|
8
|
+
* claim: a `reply` landing on the MAIN agent bridge between a flush's SYNTHETIC
|
|
9
|
+
* turn_end and the session's REAL turn_end is, by construction, that turn's own
|
|
10
|
+
* answer still being composed. The stated justification was that no other work
|
|
11
|
+
* can run on the serial claude session in that span.
|
|
12
|
+
*
|
|
13
|
+
* That is true of the session's OWN loop and false of its sub-agents. A
|
|
14
|
+
* background `Task` sub-agent runs CONCURRENTLY with the parent loop, and it
|
|
15
|
+
* calls MCP tools through the SAME plugin process and therefore the SAME IPC
|
|
16
|
+
* bridge — so `client.agentName` is the main agent and the #4172 caller-identity
|
|
17
|
+
* gate (`replyCallerIsForeignSession`) does not see it. In this fleet the
|
|
18
|
+
* `researcher` and `reviewer` sub-agent types hold the full tool set, `reply`
|
|
19
|
+
* included (`worker` holds only `progress_update`).
|
|
20
|
+
*
|
|
21
|
+
* The concrete loss that made this a MAJOR (review of #4167): the quiescence
|
|
22
|
+
* flush delivers the answer as message A and the window is OPEN; a background
|
|
23
|
+
* sub-agent calls `reply` into the same chat; owner resolution lands on the
|
|
24
|
+
* flush-ended turn (OPEN is accepted at ANY age up to the crash cap); no
|
|
25
|
+
* `subagent_handback` marker exists because a DIRECT tool call never traverses
|
|
26
|
+
* `pendingInboundBuffer.push`; `decideContentGateBypass` therefore grants
|
|
27
|
+
* `positiveAttribution`, and `decideSupersede` supersedes REGARDLESS of content
|
|
28
|
+
* — the sub-agent's message silently EDITS OVER the user's flushed answer
|
|
29
|
+
* (Telegram edits do not re-notify: both messages are lost client-side). The
|
|
30
|
+
* class pre-exists on `main` bounded by the old 60 s TTL; #4173 extends the tail
|
|
31
|
+
* to the OPEN-phase cap, which is why it is closed here rather than narrowed.
|
|
32
|
+
*
|
|
33
|
+
* ## The discriminator: gateway-observed sub-agent LIVENESS
|
|
34
|
+
*
|
|
35
|
+
* The gateway already receives the whole session-event stream, including every
|
|
36
|
+
* `sub_agent_*` kind, at `onSessionEvent` → `handleSessionEvent`. Those events
|
|
37
|
+
* are derived by the framework from the sidechain transcript
|
|
38
|
+
* (`projectSubagentLine`, session-tail.ts) — never from model discipline, never
|
|
39
|
+
* from anything a reply can steer.
|
|
40
|
+
*
|
|
41
|
+
* A sub-agent is tracked LIVE from any `sub_agent_*` event carrying its
|
|
42
|
+
* `agentId` until its `sub_agent_turn_end`. While ANY sub-agent is live, a
|
|
43
|
+
* decoupled reply on the main bridge MIGHT be that sub-agent's, so it is denied
|
|
44
|
+
* the #3429 content-gate bypass (`decideContentGateBypass`) — the reply must
|
|
45
|
+
* then BE the flushed answer to supersede it. A sub-agent's own (foreign)
|
|
46
|
+
* content therefore decides `'new-content'` and ships as a fresh, notifying
|
|
47
|
+
* message, with the flush record left intact for the turn's genuine replay.
|
|
48
|
+
*
|
|
49
|
+
* ### Why liveness and not per-reply correlation
|
|
50
|
+
*
|
|
51
|
+
* Correlating the landing MCP call with the `sub_agent_tool_use` event for that
|
|
52
|
+
* same `reply` would be exact, but the two arrive on independent transports (an
|
|
53
|
+
* MCP stdio round-trip vs. a JSONL tail), so the event can land AFTER the reply
|
|
54
|
+
* it describes — a race whose losing side is the silent edit-over. Liveness has
|
|
55
|
+
* the ordering the correlation lacks: a sub-agent cannot call a tool before it
|
|
56
|
+
* exists, and `sub_agent_started` is projected from its FIRST transcript line,
|
|
57
|
+
* which precedes any tool call it makes by a full model round-trip. The gate is
|
|
58
|
+
* therefore armed by the time the reply can exist — a structural ordering
|
|
59
|
+
* argument, not a tuned delay.
|
|
60
|
+
*
|
|
61
|
+
* ### Failure directions (all fail SAFE)
|
|
62
|
+
*
|
|
63
|
+
* - A sub-agent whose `sub_agent_turn_end` is never observed (capped, killed,
|
|
64
|
+
* crashed tail) stays live: the content gate holds, so the worst case is a
|
|
65
|
+
* REWORDED own-answer shipping as a visible duplicate. Never an edit-over.
|
|
66
|
+
* `reset()` on bridge death bounds it — the sub-agents die with the session.
|
|
67
|
+
* - A sub-agent steered after its `turn_end` (switchroom's `SendMessage`
|
|
68
|
+
* pattern) re-arms on its next `sub_agent_*` event, which again precedes any
|
|
69
|
+
* reply it makes by a model round-trip.
|
|
70
|
+
* - No sub-agent live ⇒ the premise HOLDS and the #4166 collapse is untouched;
|
|
71
|
+
* this gate costs nothing in the non-delegating case.
|
|
72
|
+
*
|
|
73
|
+
* ### Residual, stated honestly
|
|
74
|
+
*
|
|
75
|
+
* Denying the BYPASS (rather than supersede authority wholesale) is deliberate:
|
|
76
|
+
* with the gate on, a reply still supersedes when it IS the flushed answer
|
|
77
|
+
* (`flushedAnswerMatchesReply` — equality, or containment above
|
|
78
|
+
* `SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS`). Superseding on equality is
|
|
79
|
+
* content-preserving by definition. The residual is the containment arm: a
|
|
80
|
+
* sub-agent reply that is a ≥32-char verbatim substring of the flushed answer
|
|
81
|
+
* would still collapse into it. Denying supersede outright instead would, in a
|
|
82
|
+
* delegation-heavy agent where a background worker is nearly always live,
|
|
83
|
+
* re-open #4166 for every own-answer replay — a far larger, constant cost for a
|
|
84
|
+
* class that requires a sub-agent to emit a verbatim substring of the parent's
|
|
85
|
+
* answer.
|
|
86
|
+
*
|
|
87
|
+
* Pure: a set of ids, no clock reads, no I/O. The gateway wires the feed
|
|
88
|
+
* (`onSessionEvent`) and the read (the `sendReply` deps).
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/** The read surface the reply send path consumes (injected, so the golden
|
|
92
|
+
* harness drives it without touching the singleton). */
|
|
93
|
+
export interface SubagentReplyAuthorityView {
|
|
94
|
+
/** True when at least one sub-agent is live on this session and could
|
|
95
|
+
* therefore be the author of a decoupled reply landing right now. */
|
|
96
|
+
subagentCouldOwnReply(): boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The minimal session-event shape this tracker reads. Structural so the module
|
|
100
|
+
* never imports the session-tail union (no cycle, trivially fake-able). */
|
|
101
|
+
export interface SubagentLifecycleEvent {
|
|
102
|
+
kind: string
|
|
103
|
+
agentId?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SUB_AGENT_KIND_PREFIX = 'sub_agent_'
|
|
107
|
+
|
|
108
|
+
export class SubagentReplyAuthority implements SubagentReplyAuthorityView {
|
|
109
|
+
private readonly live = new Set<string>()
|
|
110
|
+
|
|
111
|
+
/** Feed EVERY session event here. Non-`sub_agent_*` kinds are ignored, so the
|
|
112
|
+
* call site needs no filtering (and cannot drift from this one). */
|
|
113
|
+
noteSessionEvent(ev: SubagentLifecycleEvent | null | undefined): void {
|
|
114
|
+
if (ev == null) return
|
|
115
|
+
const kind = ev.kind
|
|
116
|
+
if (typeof kind !== 'string' || !kind.startsWith(SUB_AGENT_KIND_PREFIX)) return
|
|
117
|
+
const agentId = ev.agentId
|
|
118
|
+
// An id-less sub-agent event cannot be attributed to a lifecycle; ignoring
|
|
119
|
+
// it is the safe direction for `turn_end` (liveness persists → gate holds)
|
|
120
|
+
// and merely a missed arm otherwise — every other kind for a real sub-agent
|
|
121
|
+
// carries the id.
|
|
122
|
+
if (typeof agentId !== 'string' || agentId === '') return
|
|
123
|
+
if (kind === `${SUB_AGENT_KIND_PREFIX}turn_end`) this.live.delete(agentId)
|
|
124
|
+
else this.live.add(agentId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
subagentCouldOwnReply(): boolean {
|
|
128
|
+
return this.live.size > 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The bridge died: the claude session and every sub-agent under it are gone.
|
|
132
|
+
* Bounds a leaked liveness entry (see "Failure directions"). */
|
|
133
|
+
reset(): void {
|
|
134
|
+
this.live.clear()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Test/diagnostics: how many sub-agents are currently tracked live. */
|
|
138
|
+
liveCount(): number {
|
|
139
|
+
return this.live.size
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The ONE live instance (one CLI session per gateway process — the same
|
|
144
|
+
* module-singleton shape as `flushCompletionTracker` in stream-render.ts).
|
|
145
|
+
* Re-`new`ing this anywhere else splits the signal and silently re-opens the
|
|
146
|
+
* edit-over hole. */
|
|
147
|
+
export const subagentReplyAuthority = new SubagentReplyAuthority()
|
|
@@ -437,10 +437,18 @@ function endCurrentTurnAtomic(
|
|
|
437
437
|
const turnEndedAt = Date.now()
|
|
438
438
|
// 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
|
|
439
439
|
// `latest-ended` supersede tier can be recency-bounded to the
|
|
440
|
-
//
|
|
440
|
+
// turn-completion window (a stale latest-ended turn must not inherit deletion
|
|
441
441
|
// authority over a newer turn's flush record). Set once; idempotent on the
|
|
442
442
|
// deferRecord flush path (which calls this synchronously before its send).
|
|
443
443
|
turn.endedAt = turnEndedAt
|
|
444
|
+
// #4173 — default completion stamp: for every ordinary turn-end path, the
|
|
445
|
+
// turn ending IS the session's real stop, so the completion window is born
|
|
446
|
+
// closed (the latest-ended tier keeps its tight ~60 s replay grace). The ONE
|
|
447
|
+
// path where that is false — the answer-ready quiescence flush, which ends
|
|
448
|
+
// the turn synthetically while the session is still composing — re-opens the
|
|
449
|
+
// window right after this call (`flushCompletionTracker.open`, stream-render
|
|
450
|
+
// turn-flush branch) and holds it until the REAL turn_end is observed.
|
|
451
|
+
turn.realEndObservedAt = turnEndedAt
|
|
444
452
|
process.stderr.write(
|
|
445
453
|
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
446
454
|
)
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
* feeds their resolved turnIds here; the precedence lives in one place.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
// The one cross-module import this pure module takes — a sibling PURE module's
|
|
45
|
+
// constant, so the two consumers of the turn-completion window (#4173) can
|
|
46
|
+
// never disagree on the default replay grace.
|
|
47
|
+
import { SUPERSEDE_COMPLETED_GRACE_MS } from './flushed-turn-supersede.js'
|
|
48
|
+
|
|
44
49
|
/**
|
|
45
50
|
* The four owner-turn candidate ids, in the gateway's resolution precedence.
|
|
46
51
|
* Each is the `turnId` of the turn a given lookup resolved, or null when that
|
|
@@ -63,38 +68,75 @@ export interface ReplyOwnerCandidates {
|
|
|
63
68
|
latestEndedTurnId: string | null
|
|
64
69
|
/** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
|
|
65
70
|
* tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
|
|
66
|
-
* honoured ONLY
|
|
67
|
-
*
|
|
68
|
-
* resolve its owner to a NEWER turn now sitting at the
|
|
69
|
-
* delete THAT turn's legit answer.
|
|
71
|
+
* honoured ONLY within the turn-completion window (#4173 — see
|
|
72
|
+
* `latestEndedRealEndAgeMs`). Without a bound, a late reply belonging to an
|
|
73
|
+
* OLDER turn could resolve its owner to a NEWER turn now sitting at the
|
|
74
|
+
* registry tail and delete THAT turn's legit answer.
|
|
70
75
|
*
|
|
71
76
|
* Two distinct absences (#3725):
|
|
72
|
-
* - `undefined` (property omitted) ⇒
|
|
73
|
-
*
|
|
77
|
+
* - `undefined` (property omitted) ⇒ no age supplied at all — fails
|
|
78
|
+
* CLOSED since #4175 (the pre-F2 "unbounded" escape granted unbounded
|
|
79
|
+
* destructive authority exactly when the wiring was DROPPED, the
|
|
80
|
+
* dangerous direction);
|
|
74
81
|
* - explicit `null` ⇒ the caller COMPUTED no age, i.e. its candidate turn
|
|
75
|
-
* has no `endedAt` and has NOT ended. That cannot be
|
|
82
|
+
* has no `endedAt` and has NOT ended. That cannot be bounded, so it
|
|
76
83
|
* fails CLOSED (not accepted) rather than granting unbounded authority
|
|
77
84
|
* to a turn that is still running. */
|
|
78
85
|
latestEndedAgeMs?: number | null
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
86
|
+
/** Bound applied to `latestEndedAgeMs` while the turn's completion window is
|
|
87
|
+
* OPEN (#4173): for a flush-ended turn whose REAL turn_end has not been
|
|
88
|
+
* observed yet, this is the crash-backstop cap
|
|
89
|
+
* (`SUPERSEDE_OPEN_WINDOW_CAP_MS`). Undefined ⇒ fails closed (#4175). */
|
|
81
90
|
latestEndedTtlMs?: number
|
|
91
|
+
/** #4173 — the turn-completion signal, ms since the candidate turn's REAL
|
|
92
|
+
* turn_end was observed (`turn.realEndObservedAt`):
|
|
93
|
+
* - `null` ⇒ the real turn_end has NOT been observed (a flush ended this
|
|
94
|
+
* turn synthetically and the session is still composing) — the OPEN
|
|
95
|
+
* phase; acceptance falls to `latestEndedAgeMs <= latestEndedTtlMs`
|
|
96
|
+
* (the crash backstop), so a 20-minute compaction still resolves.
|
|
97
|
+
* - a number ⇒ the session stopped that long ago — the COMPLETED phase;
|
|
98
|
+
* accepted only within `latestEndedCompletedGraceMs` (the bounded
|
|
99
|
+
* tool-call-replay window). For a normally-ended turn the real end IS
|
|
100
|
+
* `endedAt`, so this equals `latestEndedAgeMs` and the tier keeps its
|
|
101
|
+
* original tight ~60 s bound.
|
|
102
|
+
* - `undefined` ⇒ legacy caller shape (no completion signal wired):
|
|
103
|
+
* falls back to the plain `age <= ttl` rule. */
|
|
104
|
+
latestEndedRealEndAgeMs?: number | null
|
|
105
|
+
/** COMPLETED-phase bound for `latestEndedRealEndAgeMs`. Undefined ⇒ the
|
|
106
|
+
* module default (`SUPERSEDE_COMPLETED_GRACE_MS`). */
|
|
107
|
+
latestEndedCompletedGraceMs?: number
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/**
|
|
85
111
|
* Whether the latest-ended candidate is fresh enough to carry supersede
|
|
86
|
-
* (deletion) authority
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
112
|
+
* (deletion) authority — the turn-completion-window rule (#4173, three phases
|
|
113
|
+
* documented on `latestEndedRealEndAgeMs` above).
|
|
114
|
+
*
|
|
115
|
+
* An EXPLICIT null age fails closed (#3725 — the caller computed no age
|
|
116
|
+
* because its candidate turn has not ended, and an un-ended turn must be
|
|
117
|
+
* resolved by the `live` tier, never by this destructive fallback). An OMITTED
|
|
118
|
+
* age or TTL also fails closed (#4175): the old "omitted ⇒ unbounded"
|
|
119
|
+
* back-compat escape meant DROPPING the gateway's bound wiring silently
|
|
120
|
+
* granted unbounded destructive authority — failing toward silent edit-over.
|
|
121
|
+
* Now dropped wiring degrades to "tier never accepted" — a visible duplicate,
|
|
122
|
+
* the safe direction.
|
|
90
123
|
*/
|
|
91
124
|
function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
92
125
|
if (candidates.latestEndedTurnId == null) return false
|
|
93
126
|
const age = candidates.latestEndedAgeMs
|
|
94
127
|
const ttl = candidates.latestEndedTtlMs
|
|
95
|
-
if (age
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
if (age == null || ttl == null) return false
|
|
129
|
+
const realEndAge = candidates.latestEndedRealEndAgeMs
|
|
130
|
+
if (realEndAge === undefined || realEndAge === null) {
|
|
131
|
+
// Legacy caller (no completion signal) or OPEN window (real turn_end not
|
|
132
|
+
// yet observed): the plain age-vs-cap rule. For an open window `ttl` is
|
|
133
|
+
// the generous crash backstop, so a long compaction still resolves.
|
|
134
|
+
return age <= ttl
|
|
135
|
+
}
|
|
136
|
+
// COMPLETED: the session stopped `realEndAge` ago — only the bounded
|
|
137
|
+
// tool-call-replay grace remains.
|
|
138
|
+
const grace = candidates.latestEndedCompletedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
|
|
139
|
+
return realEndAge <= grace
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
/**
|
|
@@ -207,6 +249,21 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
207
249
|
* `live` keeps its unconditional bypass: `currentTurn` is framework-owned, and
|
|
208
250
|
* `decideSupersede`'s same-turnId requirement already bars it from reaching a
|
|
209
251
|
* DIFFERENT ended turn's record. `none` never bypasses (nothing to attribute).
|
|
252
|
+
*
|
|
253
|
+
* ## The second ambiguity: a live sub-agent (#4176)
|
|
254
|
+
*
|
|
255
|
+
* `handbackCouldOwnReply` covers the sub-agent completion that the gateway
|
|
256
|
+
* SYNTHESIZED as an inbound. It does not cover a background sub-agent calling
|
|
257
|
+
* `reply` DIRECTLY, mid-run: that call rides the same main bridge as the parent
|
|
258
|
+
* loop (so the #4172 caller-identity gate cannot see it) and never traverses
|
|
259
|
+
* `pendingInboundBuffer.push` (so no marker is stamped). Marker-absence is only
|
|
260
|
+
* evidence of "the turn's own answer" if nothing else on the session could have
|
|
261
|
+
* emitted this reply — which is exactly what `subagentCouldOwnReply` reports,
|
|
262
|
+
* from gateway-observed `sub_agent_*` liveness. When it is true the content gate
|
|
263
|
+
* is KEPT on every non-`live` tier: a reply that IS the flushed answer still
|
|
264
|
+
* collapses, a sub-agent's foreign content sends fresh. See
|
|
265
|
+
* `gateway/subagent-reply-authority.ts` for the ordering argument and the
|
|
266
|
+
* failure directions.
|
|
210
267
|
*/
|
|
211
268
|
export function decideContentGateBypass(input: {
|
|
212
269
|
/** The winning owner tier (`resolveReplyOwnerTier`). */
|
|
@@ -216,18 +273,36 @@ export function decideContentGateBypass(input: {
|
|
|
216
273
|
/** The SAME candidate set both of the above were derived from — supplies the
|
|
217
274
|
* framework-derived `latestEndedTurnId` plus its freshness bound, so the
|
|
218
275
|
* corroboration reuses the EXACT rule `resolveReplyOwnerTier` applies
|
|
219
|
-
* (`latestEndedAccepted`) instead of duplicating it: within the
|
|
220
|
-
* since #3725 — not a turn that is
|
|
276
|
+
* (`latestEndedAccepted`) instead of duplicating it: within the
|
|
277
|
+
* turn-completion window (#4173), and — since #3725 — not a turn that is
|
|
278
|
+
* still running. */
|
|
221
279
|
candidates: ReplyOwnerCandidates
|
|
222
280
|
/** True when a decoupled-completion inbound (`subagent_handback`) was enqueued
|
|
223
|
-
* in this chat AFTER the owner turn ended and within the
|
|
224
|
-
*
|
|
225
|
-
* the
|
|
281
|
+
* in this chat AFTER the owner turn ended and within the handback recency
|
|
282
|
+
* window (`HANDBACK_RECENCY_WINDOW_MS`, #4174) — the ambiguous window where
|
|
283
|
+
* the late reply might BE that handback rather than the turn's own answer.
|
|
284
|
+
* Keeps the content gate on every non-`live` tier. */
|
|
226
285
|
handbackCouldOwnReply: boolean
|
|
286
|
+
/** #4176 — true when a sub-agent is LIVE on this session
|
|
287
|
+
* (`subagentReplyAuthority`, gateway/subagent-reply-authority.ts). A
|
|
288
|
+
* background `Task` sub-agent calls `reply` over the SAME main bridge as the
|
|
289
|
+
* parent loop, so the #4172 caller-identity gate cannot see it and — because
|
|
290
|
+
* a DIRECT tool call never traverses `pendingInboundBuffer.push` — it stamps
|
|
291
|
+
* no handback marker either. Marker-absence therefore does NOT prove "the
|
|
292
|
+
* turn's own answer" while a sub-agent is live: the reply might be the
|
|
293
|
+
* sub-agent's, and bypassing the content gate would silently EDIT OVER the
|
|
294
|
+
* flushed answer. Typed REQUIRED and evaluated FAIL-CLOSED (anything but an
|
|
295
|
+
* explicit `false` keeps the gate): `telegram-plugin/` is not covered by
|
|
296
|
+
* `tsc --noEmit`, so the type alone cannot stop a caller from dropping the
|
|
297
|
+
* wiring — and the #4175 precedent is that a dropped bound fails closed,
|
|
298
|
+
* never open. */
|
|
299
|
+
subagentCouldOwnReply: boolean
|
|
227
300
|
}): boolean {
|
|
228
301
|
if (input.tier === 'live') return true
|
|
229
302
|
if (input.tier === 'none') return false
|
|
230
303
|
if (input.handbackCouldOwnReply) return false
|
|
304
|
+
// Fail-CLOSED on an omitted flag (see the field doc): `!== false`, not truthiness.
|
|
305
|
+
if (input.subagentCouldOwnReply !== false) return false
|
|
231
306
|
// Total over degenerate input (#3726). TypeScript makes `candidates` mandatory
|
|
232
307
|
// and the one production caller (`resolveReplyOwnerTurn`) always builds it, so
|
|
233
308
|
// this cannot fire today — but this module is exported precisely so the
|
|
@@ -260,16 +335,17 @@ export function decideContentGateBypass(input: {
|
|
|
260
335
|
* handback pattern (#3426): the parent turn's interim ack (a substantive
|
|
261
336
|
* `reply`) armed the latch, the turn ended, and the sub-agent completion
|
|
262
337
|
* handback — a genuinely NEW answer arriving with no live gateway turn —
|
|
263
|
-
* resolved the ended turn as owner (latest-ended tier, inside the 60 s
|
|
264
|
-
* supersede
|
|
338
|
+
* resolved the ended turn as owner (latest-ended tier, inside the — then 60 s —
|
|
339
|
+
* supersede window), saw the stale latch, and was silently dropped with a false
|
|
265
340
|
* "deduped" success. Tagging the source lets the suppression fire ONLY for the
|
|
266
341
|
* flush races it exists for; byte-identical replays of a reply-delivered
|
|
267
342
|
* answer remain covered by the content-keyed outbound dedup (#546).
|
|
268
343
|
*
|
|
269
344
|
* Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
|
|
270
|
-
* RECORD time, while the latest-ended owner tier's
|
|
271
|
-
* turn's
|
|
272
|
-
*
|
|
345
|
+
* RECORD time, while the latest-ended owner tier's bound is the turn-completion
|
|
346
|
+
* window (#4173) anchored at the turn's OBSERVED real end — later by the
|
|
347
|
+
* reply→turn_end gap. A byte-identical replay landing >60 s after record but
|
|
348
|
+
* still inside the completion window is evicted from dedup yet
|
|
273
349
|
* still resolves the ended turn, so it DELIVERS as a duplicate message. That
|
|
274
350
|
* is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
|
|
275
351
|
* duplicate" suppression the reply-armed boolean latch used to provide —
|
|
@@ -22,6 +22,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
22
22
|
import { EventEmitter } from 'node:events'
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
+
buildRelayArgs,
|
|
25
26
|
parseLoopbackRedirect,
|
|
26
27
|
extractConsentUrl,
|
|
27
28
|
parseConsentUrl,
|
|
@@ -492,6 +493,7 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
|
|
|
492
493
|
email: 'user@example.com',
|
|
493
494
|
replace: false,
|
|
494
495
|
write: false,
|
|
496
|
+
calendar: false,
|
|
495
497
|
orgMode: false,
|
|
496
498
|
})
|
|
497
499
|
})
|
|
@@ -501,6 +503,26 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
|
|
|
501
503
|
expect(p).toMatchObject({ kind: 'provider-add', replace: true, write: true })
|
|
502
504
|
})
|
|
503
505
|
|
|
506
|
+
it('parses google --calendar, and it does not imply --write', () => {
|
|
507
|
+
const p = parseAuthCommand('/auth google add user@example.com --replace --calendar')
|
|
508
|
+
expect(p).toMatchObject({
|
|
509
|
+
kind: 'provider-add',
|
|
510
|
+
replace: true,
|
|
511
|
+
calendar: true,
|
|
512
|
+
write: false,
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('parses --write and --calendar together', () => {
|
|
517
|
+
const p = parseAuthCommand('/auth google add user@example.com --write --calendar')
|
|
518
|
+
expect(p).toMatchObject({ kind: 'provider-add', write: true, calendar: true })
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('--calendar is google-only — microsoft rejects it as an unknown flag', () => {
|
|
522
|
+
const p = parseAuthCommand('/auth microsoft add user@corp.com --calendar')
|
|
523
|
+
expect(p).toMatchObject({ kind: 'help' })
|
|
524
|
+
})
|
|
525
|
+
|
|
504
526
|
it('parses /auth microsoft add with --org-mode', () => {
|
|
505
527
|
const p = parseAuthCommand('/auth microsoft add user@corp.com --org-mode')
|
|
506
528
|
expect(p).toMatchObject({
|
|
@@ -531,3 +553,39 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
|
|
|
531
553
|
expect(p).toMatchObject({ kind: 'help' })
|
|
532
554
|
})
|
|
533
555
|
})
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
describe('buildRelayArgs — chat flags actually reach the CLI', () => {
|
|
559
|
+
it('bare google add: no opt-in flags', () => {
|
|
560
|
+
expect(buildRelayArgs('google', 'a@b.com', {})).toEqual([
|
|
561
|
+
'auth', 'google', 'account', 'add', 'a@b.com',
|
|
562
|
+
])
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('forwards --calendar', () => {
|
|
566
|
+
expect(buildRelayArgs('google', 'a@b.com', { calendar: true })).toEqual([
|
|
567
|
+
'auth', 'google', 'account', 'add', 'a@b.com', '--calendar',
|
|
568
|
+
])
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('forwards --replace --write --calendar together, in order', () => {
|
|
572
|
+
expect(
|
|
573
|
+
buildRelayArgs('google', 'a@b.com', {
|
|
574
|
+
replace: true,
|
|
575
|
+
write: true,
|
|
576
|
+
calendar: true,
|
|
577
|
+
}),
|
|
578
|
+
).toEqual([
|
|
579
|
+
'auth', 'google', 'account', 'add', 'a@b.com',
|
|
580
|
+
'--replace', '--write', '--calendar',
|
|
581
|
+
])
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('--calendar never leaks into the microsoft argv', () => {
|
|
585
|
+
expect(
|
|
586
|
+
buildRelayArgs('microsoft', 'a@corp.com', { calendar: true, orgMode: true }),
|
|
587
|
+
).toEqual([
|
|
588
|
+
'auth', 'microsoft', 'account', 'add', 'a@corp.com', '--org-mode',
|
|
589
|
+
])
|
|
590
|
+
})
|
|
591
|
+
})
|