switchroom 0.19.19 → 0.19.23
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 +2 -1
- package/dist/auth-broker/index.js +121 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +2995 -1583
- package/dist/host-control/main.js +122 -2
- package/dist/vault/approvals/kernel-server.js +124 -4
- package/dist/vault/broker/server.js +124 -4
- package/package.json +7 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +873 -184
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +75 -63
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
- package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
- package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-pin-api.ts — the Bot API surface the status-pin driver calls, bound to
|
|
3
|
+
* the gateway's retry policy + send gate (#3664).
|
|
4
|
+
*
|
|
5
|
+
* Extracted out of `gateway.ts` (which is under a line ratchet,
|
|
6
|
+
* `scripts/check-gateway-line-ratchet.mjs`) so the two invariants below can be
|
|
7
|
+
* enforced in code and proven in isolation, instead of resting on defaults that
|
|
8
|
+
* live in other modules.
|
|
9
|
+
*
|
|
10
|
+
* INVARIANT 1 — the bot must exist (`assertBotReady`).
|
|
11
|
+
* `lockedBot` in gateway.ts is declared `let lockedBot!: Bot<Context>`. The
|
|
12
|
+
* definite-assignment `!` is an ASSERTION, not a guarantee: it told `tsc` to
|
|
13
|
+
* stop checking, which is exactly why the boot orphan sweep dereferencing an
|
|
14
|
+
* unset `lockedBot` at module-eval time compiled clean and shipped. Every unpin
|
|
15
|
+
* it issued failed with the opaque
|
|
16
|
+
* `undefined is not an object (evaluating 'lockedBot.api')`, which read like a
|
|
17
|
+
* Telegram fault for a month. The ordering FIX is the two-condition gate in
|
|
18
|
+
* `boot-sweep-gate.ts`; this is the BACKSTOP, so any future pre-ready caller
|
|
19
|
+
* gets a named, greppable `STATUS_PIN_BOT_NOT_READY` instead.
|
|
20
|
+
*
|
|
21
|
+
* INVARIANT 2 — a shed send must not look like a landed one (`assertLanded`).
|
|
22
|
+
* See the docblock on `assertLanded`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PinBotApi } from '../status-pin-driver.js'
|
|
26
|
+
import { SEND_GATE_SHED } from '../send-gate.js'
|
|
27
|
+
|
|
28
|
+
/** Thrown when the pin API is used before `lockedBot` has been assigned. */
|
|
29
|
+
export class BotNotReadyError extends Error {
|
|
30
|
+
constructor(what: string) {
|
|
31
|
+
super(
|
|
32
|
+
`STATUS_PIN_BOT_NOT_READY: ${what} used before initGatewayBot() assigned ` +
|
|
33
|
+
`lockedBot — the boot sweep must run behind the boot-sweep gate`,
|
|
34
|
+
)
|
|
35
|
+
this.name = 'BotNotReadyError'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Narrow `bot` to non-null or throw {@link BotNotReadyError}. */
|
|
40
|
+
export function assertBotReady<T>(bot: T | undefined | null, what: string): T {
|
|
41
|
+
if (bot == null) throw new BotNotReadyError(what)
|
|
42
|
+
return bot
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Thrown when the outbound send gate SHED a pin/unpin — it never reached Telegram. */
|
|
46
|
+
export class SendGateShedError extends Error {
|
|
47
|
+
constructor(verb: string) {
|
|
48
|
+
super(`STATUS_PIN_SEND_SHED: ${verb} was shed by the send gate and never reached Telegram`)
|
|
49
|
+
this.name = 'SendGateShedError'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Convert a send-gate SHED into a throw.
|
|
55
|
+
*
|
|
56
|
+
* The pin state machine reads "the call did not throw" as "the pin/unpin
|
|
57
|
+
* LANDED": `reconcilePin` claims the message on a resolved pin, and treats a
|
|
58
|
+
* resolved unpin as confirmed — dropping the in-memory claim AND (via the null
|
|
59
|
+
* branch of `reconcileAndPersistStatusPin`) the durable `status-pins.json` row.
|
|
60
|
+
* A send that was DROPPED but resolved success-shaped therefore reopens #3664
|
|
61
|
+
* Defect B through a second door: the still-pinned message loses its last
|
|
62
|
+
* record and no reaper or boot sweep can ever find it again.
|
|
63
|
+
*
|
|
64
|
+
* `robustApiCall` does not throw on a shed — it RESOLVES the gate's
|
|
65
|
+
* `SEND_GATE_SHED` sentinel (send-gate.ts). Today a status-pin call
|
|
66
|
+
* cannot be shed, because it is an untagged SEND and untagged sends admit as
|
|
67
|
+
* `UNTAGGED_SEND_CLASS`, which is `'critical'` and is never shed.
|
|
68
|
+
* But that is a DEFAULT IN ANOTHER MODULE: tagging `status-pin.unpin` with a
|
|
69
|
+
* `priorityClass`, or changing that default, would silently reopen the defect
|
|
70
|
+
* with no test failing. This turns the default into an enforced invariant.
|
|
71
|
+
*
|
|
72
|
+
* Sentinel ONLY. A plain `undefined` is deliberately NOT a failure: the gate
|
|
73
|
+
* also resolves `undefined` for a benign no-op drop (identical payload already
|
|
74
|
+
* on screen) and the retry policy resolves `undefined` for swallowed benign
|
|
75
|
+
* 400s. Conflating those with a shed is the exact ambiguity `SEND_GATE_SHED`
|
|
76
|
+
* was introduced to remove (see its docblock in send-gate.ts).
|
|
77
|
+
*
|
|
78
|
+
* Known residual: a `useful`-classed send whose queue TTL EXPIRES also resolves
|
|
79
|
+
* `undefined` (`send-gate.ts`, `outcome.result === 'expired'`) and so is not
|
|
80
|
+
* caught here. That is unreachable for status pins — they are untagged, hence
|
|
81
|
+
* `critical`, which is never TTL-dropped — and closing it would mean treating
|
|
82
|
+
* every benign `undefined` as a failure, which is strictly worse. If a status
|
|
83
|
+
* pin is ever deliberately tagged `useful`, this needs a distinguishable
|
|
84
|
+
* expiry sentinel too.
|
|
85
|
+
*/
|
|
86
|
+
export function assertLanded(result: unknown, verb: string): unknown {
|
|
87
|
+
if (result === SEND_GATE_SHED) throw new SendGateShedError(verb)
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Minimal shape of the wrapped gateway bot this module needs. */
|
|
92
|
+
export interface PinCapableBot {
|
|
93
|
+
api: {
|
|
94
|
+
pinChatMessage: (
|
|
95
|
+
chatId: string | number,
|
|
96
|
+
messageId: number,
|
|
97
|
+
opts?: Record<string, unknown>,
|
|
98
|
+
) => Promise<unknown>
|
|
99
|
+
unpinChatMessage: (chatId: string | number, messageId: number) => Promise<unknown>
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The gateway's `robustApiCall` seam (retry policy + send gate), erased to the
|
|
105
|
+
* shape this module needs so `gateway.ts` can pass it with a single cast.
|
|
106
|
+
*/
|
|
107
|
+
export type RobustApiSeam = (
|
|
108
|
+
fn: () => Promise<unknown>,
|
|
109
|
+
opts: Record<string, unknown>,
|
|
110
|
+
) => Promise<unknown>
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the pin API.
|
|
114
|
+
*
|
|
115
|
+
* `getBot` is read LAZILY on every call — `lockedBot` is assigned late, inside
|
|
116
|
+
* `initGatewayBot()` — and asserted non-null (INVARIANT 1). `robust` is the
|
|
117
|
+
* gateway's `robustApiCall`, so pins/unpins ride the send gate and retry policy
|
|
118
|
+
* exactly as before this extraction, with the shed sentinel converted to a
|
|
119
|
+
* throw on the way out (INVARIANT 2).
|
|
120
|
+
*/
|
|
121
|
+
export function createStatusPinApi(
|
|
122
|
+
getBot: () => PinCapableBot | undefined,
|
|
123
|
+
robust: RobustApiSeam,
|
|
124
|
+
): PinBotApi {
|
|
125
|
+
const call = (verb: string, fn: (bot: PinCapableBot) => Promise<unknown>, chatId: string) =>
|
|
126
|
+
robust(() => fn(assertBotReady(getBot(), verb)), { chat_id: chatId, verb }).then((r) =>
|
|
127
|
+
assertLanded(r, verb),
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
pinChatMessage: (chat_id, message_id, opts) =>
|
|
131
|
+
call(
|
|
132
|
+
'status-pin.pin',
|
|
133
|
+
// allow-raw-bot-api: `call` runs this inside the injected `robust` seam (robustApiCall).
|
|
134
|
+
(bot) => bot.api.pinChatMessage(chat_id, message_id, opts),
|
|
135
|
+
String(chat_id),
|
|
136
|
+
),
|
|
137
|
+
unpinChatMessage: (chat_id, message_id) =>
|
|
138
|
+
call(
|
|
139
|
+
'status-pin.unpin',
|
|
140
|
+
// allow-raw-bot-api: as above — wrapped by the injected `robust` seam.
|
|
141
|
+
(bot) => bot.api.unpinChatMessage(chat_id, message_id),
|
|
142
|
+
String(chat_id),
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -1856,6 +1856,12 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1856
1856
|
sentIds = delivery.sentIds
|
|
1857
1857
|
chunkCount = delivery.chunkCount
|
|
1858
1858
|
delivered = delivery.delivered
|
|
1859
|
+
// #3702 — how many landed ids the read-back never corroborated.
|
|
1860
|
+
// Stamped on the turn so `emitTurnRecord` writes `landed_unconfirmed`
|
|
1861
|
+
// (omitted when 0): the fleet-visible counter for deliveries we call
|
|
1862
|
+
// `complete` on the Bot API's ack alone, because the probe is
|
|
1863
|
+
// inconclusive. Observational only — it never changes the status.
|
|
1864
|
+
turn.landedUnconfirmed = delivery.landedUnconfirmed
|
|
1859
1865
|
|
|
1860
1866
|
// #546 dedup: record what turn-flush just sent so a late-arriving
|
|
1861
1867
|
// reply / stream_reply with the same content gets suppressed.
|
|
@@ -148,6 +148,20 @@ export interface TurnRecordRow {
|
|
|
148
148
|
tools: number
|
|
149
149
|
status: TurnStatus
|
|
150
150
|
turn_id: string
|
|
151
|
+
/**
|
|
152
|
+
* How many landed message ids of this turn's backstop delivery the read-back
|
|
153
|
+
* probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
|
|
154
|
+
* when zero, so an ordinary row is byte-identical to before.
|
|
155
|
+
*
|
|
156
|
+
* This is the measurable counterpart of the delivery verdict: since an
|
|
157
|
+
* inconclusive probe counts as delivered, a `complete` row carrying
|
|
158
|
+
* `landed_unconfirmed > 0` is a turn we called delivered on the Bot API's
|
|
159
|
+
* ack alone. Counting those is how the fleet can tell whether that optimism
|
|
160
|
+
* is ever wrong (a `landed_unconfirmed` turn followed by a "you never
|
|
161
|
+
* answered me" is the falsifying observation). It is NOT a failure signal and
|
|
162
|
+
* nothing escalates on it.
|
|
163
|
+
*/
|
|
164
|
+
landed_unconfirmed?: number
|
|
151
165
|
}
|
|
152
166
|
|
|
153
167
|
/**
|
|
@@ -165,6 +179,7 @@ export function buildTurnRecord(
|
|
|
165
179
|
turnId: string
|
|
166
180
|
finalAnswerDelivered: boolean
|
|
167
181
|
deliveryOutcome?: DeliveryOutcome
|
|
182
|
+
landedUnconfirmed?: number
|
|
168
183
|
},
|
|
169
184
|
endedAt: number,
|
|
170
185
|
): TurnRecordRow {
|
|
@@ -175,5 +190,9 @@ export function buildTurnRecord(
|
|
|
175
190
|
tools: turn.toolCallCount ?? 0,
|
|
176
191
|
status: computeTurnStatus(turn),
|
|
177
192
|
turn_id: turn.turnId,
|
|
193
|
+
// Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
|
|
194
|
+
...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
|
|
195
|
+
? { landed_unconfirmed: turn.landedUnconfirmed }
|
|
196
|
+
: {}),
|
|
178
197
|
}
|
|
179
198
|
}
|
|
@@ -13,6 +13,71 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const TURNS_JSONL_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB
|
|
15
15
|
|
|
16
|
+
/** The agent state dir inside a switchroom agent container (bind-mounted to
|
|
17
|
+
* `~/.switchroom/agents/<name>/` on the host). */
|
|
18
|
+
export const DEFAULT_AGENT_STATE_DIR = '/state/agent'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the agent state dir from the environment — the ONE reader for
|
|
22
|
+
* `SWITCHROOM_AGENT_STATE_DIR` that every writer into that dir must use.
|
|
23
|
+
*
|
|
24
|
+
* Normalisation is the point. The gateway had two writers into this dir a few
|
|
25
|
+
* lines apart (the context-occupancy snapshot and the turn record) reading the
|
|
26
|
+
* env var with two different expressions: a bare
|
|
27
|
+
* `process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent'` and this one. For
|
|
28
|
+
* a value like `"/x/ "` or `"/x/"` those resolve to DIFFERENT directories, so
|
|
29
|
+
* the two artifacts of the same turn would land in two places — exactly the
|
|
30
|
+
* kind of split-brain state that made the turn-record leak hard to see. Both
|
|
31
|
+
* call sites now share this function.
|
|
32
|
+
*
|
|
33
|
+
* Blank/whitespace-only is treated as unset (a compose file that renders an
|
|
34
|
+
* empty value must not send state to `/turns.jsonl` at the filesystem root),
|
|
35
|
+
* and a trailing slash is stripped so joins never double it.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveAgentStateDir(
|
|
38
|
+
env: Record<string, string | undefined> = process.env,
|
|
39
|
+
): string {
|
|
40
|
+
const dir = env.SWITCHROOM_AGENT_STATE_DIR?.trim()
|
|
41
|
+
return dir != null && dir !== '' ? dir.replace(/\/+$/, '') : DEFAULT_AGENT_STATE_DIR
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the turn-record path from the environment.
|
|
46
|
+
*
|
|
47
|
+
* `emitTurnRecord` used to hard-code `/state/agent/turns.jsonl`, ignoring
|
|
48
|
+
* `SWITCHROOM_AGENT_STATE_DIR` — which the sibling context-occupancy writer a
|
|
49
|
+
* few lines above it in `gateway.ts` already honours. Inside an agent container
|
|
50
|
+
* that path is the bind-mounted PRODUCTION `~/.switchroom/agents/<name>/
|
|
51
|
+
* turns.jsonl`, so any test that drove the real turn-end funnel while running
|
|
52
|
+
* in an agent container appended its synthetic rows straight into that agent's
|
|
53
|
+
* production turn record — even when the test had pointed every state-dir env
|
|
54
|
+
* var at a tmpdir. Those rows are then read back by the fleet-health L0 sensor
|
|
55
|
+
* (`src/fleet-health/scan.ts`) as that agent's real production turns.
|
|
56
|
+
*
|
|
57
|
+
* Honouring the env var is the root-cause fix: production containers do not set
|
|
58
|
+
* it (default unchanged), and a test that isolates its state dir now isolates
|
|
59
|
+
* its turn records with it.
|
|
60
|
+
*
|
|
61
|
+
* ── Operator coupling: setting this var RELOCATES the fleet-health input ──
|
|
62
|
+
*
|
|
63
|
+
* `agent.env` in `switchroom.yaml` is propagated verbatim into the container
|
|
64
|
+
* (`src/agents/compose.ts` `userEnv`), so an operator CAN set
|
|
65
|
+
* `SWITCHROOM_AGENT_STATE_DIR` on an agent. If they point it anywhere other
|
|
66
|
+
* than the bind-mounted `/state/agent`, this file moves with it — but the
|
|
67
|
+
* fleet-health L0 sensor reads `~/.switchroom/agents/<name>/turns.jsonl` at a
|
|
68
|
+
* FIXED host path (`src/fleet-health/scan.ts`). That agent then presents no
|
|
69
|
+
* turns artifact, lands in the scan's `skipped[]`, and goes quiet on the health
|
|
70
|
+
* board: no findings, no ledger entries, indistinguishable from a healthy
|
|
71
|
+
* agent. Do not set this var in production `agent.env`; it exists so tests (and
|
|
72
|
+
* the vitest `agent-state-dir-guard` setup file) can isolate agent state into a
|
|
73
|
+
* tmpdir.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveTurnsJsonlPath(
|
|
76
|
+
env: Record<string, string | undefined> = process.env,
|
|
77
|
+
): string {
|
|
78
|
+
return `${resolveAgentStateDir(env)}/turns.jsonl`
|
|
79
|
+
}
|
|
80
|
+
|
|
16
81
|
export interface RotateFs {
|
|
17
82
|
statSize: (path: string) => number | undefined // undefined ⇒ file absent
|
|
18
83
|
rename: (from: string, to: string) => void
|