switchroom 0.19.17 → 0.19.19
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/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +13 -4
- package/dist/auth-broker/index.js +32 -5
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +13 -4
- package/dist/cli/switchroom.js +10614 -8104
- package/dist/host-control/main.js +12849 -11446
- package/dist/vault/approvals/kernel-server.js +90 -12
- package/dist/vault/broker/server.js +277 -94
- package/package.json +5 -3
- package/profiles/_base/start.sh.hbs +69 -5
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +25 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +28 -20
- package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
- package/telegram-plugin/dist/server.js +32 -20
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -193,6 +193,8 @@ export interface MidTurnFloorContext {
|
|
|
193
193
|
export type SilencePokeMetric =
|
|
194
194
|
| { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
|
|
195
195
|
| { kind: 'mid_turn_floor'; key: string; silence_ms: number; forced: boolean; decision: 'fire' | string }
|
|
196
|
+
/** #3552 — per-turn state dropped because its turn is provably over. */
|
|
197
|
+
| { kind: 'silence_poke_orphan_reaped'; key: string; silence_ms: number }
|
|
196
198
|
|
|
197
199
|
export interface SilencePokeDeps {
|
|
198
200
|
/** Called when the 300s fallback fires. Caller sends the user-visible
|
|
@@ -239,6 +241,30 @@ export interface SilencePokeDeps {
|
|
|
239
241
|
* combines both. Returns null when there is no live turn for `key`.
|
|
240
242
|
*/
|
|
241
243
|
floorState?: (key: string) => { role: LoopRole; finalAnswerDelivered: boolean } | null
|
|
244
|
+
/**
|
|
245
|
+
* #3552 — orphan-state reaper predicate. Returns true while `key` still has a
|
|
246
|
+
* LIVE turn behind it, false once the turn is provably over.
|
|
247
|
+
*
|
|
248
|
+
* Why: `startTurn(key)` arms per-turn state that only `endTurn(key)` drops,
|
|
249
|
+
* and several turn-end paths never reach an `endTurn` call — a bridge death
|
|
250
|
+
* (`disconnect-flush`), a keyed teardown that bypasses the `turn_end` handler,
|
|
251
|
+
* or any future path that nulls the turn without the cleanup tail. The state
|
|
252
|
+
* then survives its turn and sits in the Map until the 300s fallback fires
|
|
253
|
+
* against a dead key, is recognised as a late fire, logs
|
|
254
|
+
* `turn_ended_cleanly_during_window` and only THEN calls `endTurn`. Measured:
|
|
255
|
+
* 504 such skips vs 110 real fires in 14 days (switchroom#3552).
|
|
256
|
+
*
|
|
257
|
+
* Wiring this callback moves that cleanup from "300s later, at fire time" to
|
|
258
|
+
* "the next poll tick", deterministically and independent of which path ended
|
|
259
|
+
* the turn. The predicate MUST be the same one the fallback's late-fire guard
|
|
260
|
+
* uses (`activeTurnStartedAt` empty AND no current turn) so a turn that has
|
|
261
|
+
* merely released its buffer gate mid-turn — the normal post-final-answer
|
|
262
|
+
* state, where silence-poke must keep watching — is never reaped.
|
|
263
|
+
*
|
|
264
|
+
* Optional: when absent (test harnesses) the reaper is off and behaviour is
|
|
265
|
+
* exactly as before.
|
|
266
|
+
*/
|
|
267
|
+
isTurnLive?: (key: string) => boolean
|
|
242
268
|
}
|
|
243
269
|
|
|
244
270
|
const state = new Map<string, SilencePokeState>()
|
|
@@ -505,6 +531,105 @@ export function formatFrameworkFallbackText(
|
|
|
505
531
|
return null
|
|
506
532
|
}
|
|
507
533
|
|
|
534
|
+
/**
|
|
535
|
+
* #3551 — inputs to the silence-fallback TEARDOWN NOTICE decision.
|
|
536
|
+
*
|
|
537
|
+
* Distinct from `formatFrameworkFallbackText`, which answers "is anything
|
|
538
|
+
* happening worth narrating?" (and, since the stall-notice was retired,
|
|
539
|
+
* answers `null` on every branch but `blockedOnApproval`). This one answers a
|
|
540
|
+
* different, terminal question: "the framework is about to END this live turn
|
|
541
|
+
* — does the user need to be told?"
|
|
542
|
+
*/
|
|
543
|
+
export interface FallbackTeardownNoticeInput {
|
|
544
|
+
/** This fire is genuinely tearing down a live turn for THIS chat/thread.
|
|
545
|
+
* False on the multi-chat case where the current turn belongs elsewhere, or
|
|
546
|
+
* when there was no turn left to end. */
|
|
547
|
+
tearsDownLiveTurn: boolean
|
|
548
|
+
/** Loop role of the turn being torn down; null when unknown. */
|
|
549
|
+
role: LoopRole | null
|
|
550
|
+
/** A final answer already landed on this turn. */
|
|
551
|
+
finalAnswerDelivered: boolean
|
|
552
|
+
/** This fire ALREADY DELIVERED a LOUD, user-addressed message about why
|
|
553
|
+
* nothing is happening — in practice the approval re-ping ("waiting for your
|
|
554
|
+
* approval — tap Approve or Deny"). Never stack a second message on that.
|
|
555
|
+
*
|
|
556
|
+
* #3551 L1: deliberately NARROWER than "some text went out". The other text
|
|
557
|
+
* this fire can emit is `formatUpdateStatusLine`, a SILENT status surface
|
|
558
|
+
* about an unrelated in-flight `update_apply` that says nothing about this
|
|
559
|
+
* turn ending — a user who asked a question during an update and got killed
|
|
560
|
+
* anyway is exactly the case this notice exists for, so that line must NOT
|
|
561
|
+
* suppress it.
|
|
562
|
+
* #3551 L2: means DELIVERED, not "attempted". The caller's send is wrapped in
|
|
563
|
+
* a log-only try/catch, so a throwing re-ping must not buy silence. */
|
|
564
|
+
userAddressedTextDelivered: boolean
|
|
565
|
+
/** The obligation ledger is on, so the killed turn's unanswered inbound will
|
|
566
|
+
* be re-presented. Governs the tail sentence — never claim a re-ask that
|
|
567
|
+
* cannot happen. */
|
|
568
|
+
representWillFollow: boolean
|
|
569
|
+
silenceMs: number
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export type FallbackTeardownNoticeDecision =
|
|
573
|
+
| {
|
|
574
|
+
send: false
|
|
575
|
+
reason:
|
|
576
|
+
| 'user-addressed-text-delivered'
|
|
577
|
+
| 'no-live-turn-torn-down'
|
|
578
|
+
| 'non-user-turn'
|
|
579
|
+
| 'answer-already-delivered'
|
|
580
|
+
}
|
|
581
|
+
| { send: true; text: string }
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* #3551 — decide whether a silence-poke fire that ENDS a live turn must say so.
|
|
585
|
+
*
|
|
586
|
+
* The bug this closes: on the non-approval branch the 300s fallback delivered
|
|
587
|
+
* NO text (`formatFrameworkFallbackText` returns null) while still tearing the
|
|
588
|
+
* turn down and redelivering buffered inbound. From the user's seat: five
|
|
589
|
+
* minutes of nothing, then the agent inexplicably restarting the same request,
|
|
590
|
+
* with no signal that the framework — not the agent — made that happen.
|
|
591
|
+
* Measured: 110 fires / 14 days, p50 silence 302s, ~9h of dead air.
|
|
592
|
+
*
|
|
593
|
+
* The fix is NOT to stop killing the turn. The teardown is the fallback's one
|
|
594
|
+
* genuinely load-bearing job (`#1122`: without it every later inbound queues
|
|
595
|
+
* behind a dead turn forever), and removing it would trade a silent turn for a
|
|
596
|
+
* permanently wedged conversation. The fix is to make the kill OBSERVABLE.
|
|
597
|
+
*
|
|
598
|
+
* This is deliberately NOT a re-introduction of the retired stall notice, and
|
|
599
|
+
* the gates below are what keep it that way:
|
|
600
|
+
* - `userAddressedTextDelivered` — never stack on a delivered approval
|
|
601
|
+
* re-ping. Narrow by design: a SILENT, unrelated update-status line does
|
|
602
|
+
* not buy silence here (#3551 L1), and an approval re-ping that threw on
|
|
603
|
+
* send does not either (#3551 L2).
|
|
604
|
+
* - `tearsDownLiveTurn` — a late-fire against an already-dead key says nothing.
|
|
605
|
+
* - `role !== 'user'` — a cron/system turn's silence is legitimate; nobody is
|
|
606
|
+
* waiting, so its teardown is housekeeping, not news.
|
|
607
|
+
* - `finalAnswerDelivered` — the user already has their answer; ending the
|
|
608
|
+
* residual housekeeping turn is invisible to them and needs no notice.
|
|
609
|
+
* What remains is exactly one case: a HUMAN asked something, got nothing for
|
|
610
|
+
* N minutes, and the framework just ended that attempt. That is a terminal
|
|
611
|
+
* event, fired at most once per turn (the caller drops the key immediately
|
|
612
|
+
* after), not a cadence-based progress ping — the thing
|
|
613
|
+
* `reference/rfcs/conversational-pacing.md` § Anti-patterns bans.
|
|
614
|
+
*/
|
|
615
|
+
export function decideFallbackTeardownNotice(
|
|
616
|
+
input: FallbackTeardownNoticeInput,
|
|
617
|
+
): FallbackTeardownNoticeDecision {
|
|
618
|
+
if (input.userAddressedTextDelivered)
|
|
619
|
+
return { send: false, reason: 'user-addressed-text-delivered' }
|
|
620
|
+
if (!input.tearsDownLiveTurn) return { send: false, reason: 'no-live-turn-torn-down' }
|
|
621
|
+
if (input.role !== 'user') return { send: false, reason: 'non-user-turn' }
|
|
622
|
+
if (input.finalAnswerDelivered) return { send: false, reason: 'answer-already-delivered' }
|
|
623
|
+
const minutes = Math.max(1, Math.round(input.silenceMs / 60_000))
|
|
624
|
+
const tail = input.representWillFollow
|
|
625
|
+
? 'Your message is still tracked and is being re-asked now.'
|
|
626
|
+
: 'Your message was not answered — please re-send it.'
|
|
627
|
+
return {
|
|
628
|
+
send: true,
|
|
629
|
+
text: `⚠️ no output for ${minutes} min — the framework ended that stalled turn. ${tail}`,
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
508
633
|
/**
|
|
509
634
|
* #2995 — the LONGEST-running in-flight tool for a turn key, or null when
|
|
510
635
|
* none is tracked. Read-only accessor over `inFlightTools` for the
|
|
@@ -603,6 +728,19 @@ function tick(now: number): void {
|
|
|
603
728
|
const silence = now - zeroAt
|
|
604
729
|
if (silence < 0) continue
|
|
605
730
|
|
|
731
|
+
// #3552 — orphan reap. Drop per-turn state the moment its turn is provably
|
|
732
|
+
// over, instead of leaving it armed until the 300s fallback fires against a
|
|
733
|
+
// dead key and disarms it as a "late fire". Deterministic and path-agnostic:
|
|
734
|
+
// it does not matter WHICH turn-end path forgot to call `endTurn`, the state
|
|
735
|
+
// cannot outlive the turn by more than one poll interval. Same predicate as
|
|
736
|
+
// the fallback's late-fire guard, so a mid-turn buffer-gate release (the
|
|
737
|
+
// normal post-final-answer state, turn still live) is never reaped.
|
|
738
|
+
if (activeDeps.isTurnLive != null && !activeDeps.isTurnLive(key)) {
|
|
739
|
+
state.delete(key)
|
|
740
|
+
activeDeps.emitMetric({ kind: 'silence_poke_orphan_reaped', key, silence_ms: silence })
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
|
|
606
744
|
// #2527 — the early, quiet mid-turn liveness beat (below the fallback
|
|
607
745
|
// window). Evaluated every tick; fires at most once per turn.
|
|
608
746
|
tryMidTurnFloor(key, s, now, false)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KEN-130 — boot-card drift probe. Outcome-asserting: a staled stamped
|
|
3
|
+
* surface or a host-written drift report produces a degraded probe row
|
|
4
|
+
* naming the surfaces; a clean agent dir stays ok (silent-when-healthy —
|
|
5
|
+
* ok probes never render a boot-card row).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { probeDrift } from '../gateway/boot-probes.js'
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_MD_YOURS_MARKER,
|
|
15
|
+
computeConfigHash,
|
|
16
|
+
computeStampFilesFromDisk,
|
|
17
|
+
writeGenerationStamp,
|
|
18
|
+
} from '../../src/agents/generation-stamp.js'
|
|
19
|
+
|
|
20
|
+
let dir: string
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), 'boot-drift-'))
|
|
23
|
+
})
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function seedAndStamp(): void {
|
|
29
|
+
writeFileSync(join(dir, 'start.sh'), '#!/bin/bash\necho hi\n')
|
|
30
|
+
writeFileSync(
|
|
31
|
+
join(dir, 'CLAUDE.md'),
|
|
32
|
+
`# Agent\nmanaged\n\n${CLAUDE_MD_YOURS_MARKER}\n\nyours\n`,
|
|
33
|
+
)
|
|
34
|
+
writeFileSync(join(dir, '.mcp.json'), '{"mcpServers":{}}\n')
|
|
35
|
+
writeGenerationStamp(dir, {
|
|
36
|
+
switchroomVersion: '1.0.0',
|
|
37
|
+
configHash: computeConfigHash({ model: 'opus' }),
|
|
38
|
+
files: computeStampFilesFromDisk(dir),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('probeDrift', () => {
|
|
43
|
+
it('clean agent dir with stamp → ok (no boot-card row)', async () => {
|
|
44
|
+
seedAndStamp()
|
|
45
|
+
const r = await probeDrift(dir)
|
|
46
|
+
expect(r.status).toBe('ok')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('no stamp and no report (fresh agent / pre-KEN-130) → ok', async () => {
|
|
50
|
+
const r = await probeDrift(dir)
|
|
51
|
+
expect(r.status).toBe('ok')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('tampered start.sh → degraded row naming start.sh with a next step', async () => {
|
|
55
|
+
seedAndStamp()
|
|
56
|
+
writeFileSync(join(dir, 'start.sh'), '#!/bin/bash\nTAMPERED\n')
|
|
57
|
+
const r = await probeDrift(dir, { agentName: 'bot' })
|
|
58
|
+
expect(r.status).toBe('degraded')
|
|
59
|
+
expect(r.detail).toContain('start.sh')
|
|
60
|
+
expect(r.nextStep).toContain('switchroom apply')
|
|
61
|
+
expect(r.nextStep).toContain('reconcile bot')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('operator edit BELOW the CLAUDE.md marker → still ok', async () => {
|
|
65
|
+
seedAndStamp()
|
|
66
|
+
writeFileSync(
|
|
67
|
+
join(dir, 'CLAUDE.md'),
|
|
68
|
+
`# Agent\nmanaged\n\n${CLAUDE_MD_YOURS_MARKER}\n\ncompletely new operator prose\n`,
|
|
69
|
+
)
|
|
70
|
+
const r = await probeDrift(dir)
|
|
71
|
+
expect(r.status).toBe('ok')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('host drift report findings surface on the row (with age when stale)', async () => {
|
|
75
|
+
seedAndStamp()
|
|
76
|
+
// Backdate the stamp so the report post-dates the last apply (a
|
|
77
|
+
// report older than the stamp is legitimately suppressed — see the
|
|
78
|
+
// superseded-by-apply test below).
|
|
79
|
+
writeGenerationStamp(dir, {
|
|
80
|
+
generatedAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
|
|
81
|
+
switchroomVersion: '1.0.0',
|
|
82
|
+
configHash: computeConfigHash({ model: 'opus' }),
|
|
83
|
+
files: computeStampFilesFromDisk(dir),
|
|
84
|
+
})
|
|
85
|
+
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000).toISOString()
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(dir, '.switchroom-drift.json'),
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
version: 1,
|
|
90
|
+
generatedAt: twoDaysAgo,
|
|
91
|
+
findings: [
|
|
92
|
+
{ surface: 'compose', detail: 'deployed compose differs' },
|
|
93
|
+
{ surface: 'hook-scripts', detail: 'image scripts stale' },
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
const r = await probeDrift(dir)
|
|
98
|
+
expect(r.status).toBe('degraded')
|
|
99
|
+
expect(r.detail).toContain('compose')
|
|
100
|
+
expect(r.detail).toContain('hook-scripts')
|
|
101
|
+
expect(r.detail).toContain('ago') // stale-report age annotation
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('report older than the stamp (apply healed it) → ok, findings suppressed', async () => {
|
|
105
|
+
// Doctor found drift, then the operator ran `switchroom apply` — the
|
|
106
|
+
// reconcile rewrote the stamp but NOT the doctor report. A freshly
|
|
107
|
+
// applied fleet must be silent; the stale report is superseded.
|
|
108
|
+
writeFileSync(
|
|
109
|
+
join(dir, '.switchroom-drift.json'),
|
|
110
|
+
JSON.stringify({
|
|
111
|
+
version: 1,
|
|
112
|
+
generatedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
|
113
|
+
findings: [{ surface: 'compose', detail: 'was drifted before apply' }],
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
seedAndStamp() // stamp written AFTER the report
|
|
117
|
+
const r = await probeDrift(dir)
|
|
118
|
+
expect(r.status).toBe('ok')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('report newer than the stamp still surfaces (drift found after apply)', async () => {
|
|
122
|
+
seedAndStamp()
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(dir, '.switchroom-drift.json'),
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
version: 1,
|
|
127
|
+
generatedAt: new Date(Date.now() + 1000).toISOString(),
|
|
128
|
+
findings: [{ surface: 'hook-scripts', detail: 'image scripts stale' }],
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
const r = await probeDrift(dir)
|
|
132
|
+
expect(r.status).toBe('degraded')
|
|
133
|
+
expect(r.detail).toContain('hook-scripts')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('empty host report (doctor ran clean) → ok', async () => {
|
|
137
|
+
seedAndStamp()
|
|
138
|
+
writeFileSync(
|
|
139
|
+
join(dir, '.switchroom-drift.json'),
|
|
140
|
+
JSON.stringify({ version: 1, generatedAt: new Date().toISOString(), findings: [] }),
|
|
141
|
+
)
|
|
142
|
+
const r = await probeDrift(dir)
|
|
143
|
+
expect(r.status).toBe('ok')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('corrupt report is ignored, never degrades the boot card', async () => {
|
|
147
|
+
seedAndStamp()
|
|
148
|
+
writeFileSync(join(dir, '.switchroom-drift.json'), 'not json at all')
|
|
149
|
+
const r = await probeDrift(dir)
|
|
150
|
+
expect(r.status).toBe('ok')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { buildEffectiveToolSchemas } from '../bridge/tool-filter.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Half-wiring guard: every tool the gateway is willing to EXECUTE must be
|
|
8
|
+
* ADVERTISED by the bridge's MCP surface.
|
|
9
|
+
*
|
|
10
|
+
* The failure this pins (observed live 2026-07-25): `progress_update` was in
|
|
11
|
+
* the gateway's `ALLOWED_TOOLS` allowlist and had a full `executeProgressUpdate`
|
|
12
|
+
* implementation, but it was never carried over into `bridge/bridge.ts`'s
|
|
13
|
+
* `TOOL_SCHEMAS` when the legacy `server.ts` monolith was deleted (5496f792,
|
|
14
|
+
* Wave 3 F4). The monolith had registered it (7206817a); the bridge never did.
|
|
15
|
+
* Result: the tool was documented in every sub-agent's system prompt, executable
|
|
16
|
+
* by the gateway, and completely unreachable — a sub-agent calling it got
|
|
17
|
+
* "No such tool available". Nothing failed loudly; the capability just silently
|
|
18
|
+
* did not exist for ~14 months.
|
|
19
|
+
*
|
|
20
|
+
* Why structural (source text, not imports): `gateway.ts` has heavy import-time
|
|
21
|
+
* side effects (socket bind, bot construction) and `bridge.ts` exits the process
|
|
22
|
+
* at import when no gateway socket is present, so neither can be imported into a
|
|
23
|
+
* unit test. Both lists are plain string literals in source, so parsing them is
|
|
24
|
+
* exact and cheap. If either declaration is refactored out of the shape these
|
|
25
|
+
* regexes expect, the test FAILS (it never silently degrades to a no-op) —
|
|
26
|
+
* update the parser, don't delete the assertion.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const PLUGIN_DIR = join(import.meta.dirname, '..')
|
|
30
|
+
|
|
31
|
+
function readSource(rel: string): string {
|
|
32
|
+
return readFileSync(join(PLUGIN_DIR, rel), 'utf8')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Tool names in the gateway's IPC execution allowlist (`ALLOWED_TOOLS`). */
|
|
36
|
+
function gatewayAllowedTools(): string[] {
|
|
37
|
+
const src = readSource('gateway/gateway.ts')
|
|
38
|
+
const m = src.match(/const ALLOWED_TOOLS = new Set\(\[([\s\S]*?)\]\)/)
|
|
39
|
+
if (m == null) throw new Error('could not locate ALLOWED_TOOLS in gateway/gateway.ts')
|
|
40
|
+
return [...m[1].matchAll(/'([a-z0-9_]+)'/g)].map((x) => x[1])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Tool names advertised by the bridge's MCP `tools/list` (`TOOL_SCHEMAS`). */
|
|
44
|
+
function bridgeToolSchemaNames(): string[] {
|
|
45
|
+
const src = readSource('bridge/bridge.ts')
|
|
46
|
+
const m = src.match(/const TOOL_SCHEMAS = \[([\s\S]*?)\n\]\n/)
|
|
47
|
+
if (m == null) throw new Error('could not locate TOOL_SCHEMAS in bridge/bridge.ts')
|
|
48
|
+
return [...m[1].matchAll(/^ {4}name: '([a-z0-9_]+)',$/gm)].map((x) => x[1])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tools deliberately executable-but-unadvertised. EMPTY on purpose.
|
|
53
|
+
*
|
|
54
|
+
* Adding a name here is a decision to make a capability unreachable by the
|
|
55
|
+
* model — it needs a comment naming the reason, not just an entry. The whole
|
|
56
|
+
* point of this file is that the omission is explicit and reviewed rather than
|
|
57
|
+
* an accident of a refactor.
|
|
58
|
+
*/
|
|
59
|
+
const INTENTIONALLY_UNADVERTISED: ReadonlySet<string> = new Set([])
|
|
60
|
+
|
|
61
|
+
describe('bridge MCP tool surface ↔ gateway ALLOWED_TOOLS parity', () => {
|
|
62
|
+
it('parses both declarations (guards against a silently no-op test)', () => {
|
|
63
|
+
expect(gatewayAllowedTools().length).toBeGreaterThan(15)
|
|
64
|
+
expect(bridgeToolSchemaNames().length).toBeGreaterThan(15)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('advertises every gateway-executable tool', () => {
|
|
68
|
+
const advertised = new Set(bridgeToolSchemaNames())
|
|
69
|
+
const missing = gatewayAllowedTools().filter(
|
|
70
|
+
(t) => !advertised.has(t) && !INTENTIONALLY_UNADVERTISED.has(t),
|
|
71
|
+
)
|
|
72
|
+
expect(missing).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('advertises no tool the gateway would refuse to execute', () => {
|
|
76
|
+
const allowed = new Set(gatewayAllowedTools())
|
|
77
|
+
const orphans = bridgeToolSchemaNames().filter((t) => !allowed.has(t))
|
|
78
|
+
expect(orphans).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('registers progress_update — the tool this guard was written for', () => {
|
|
82
|
+
expect(bridgeToolSchemaNames()).toContain('progress_update')
|
|
83
|
+
expect(gatewayAllowedTools()).toContain('progress_update')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keeps progress_update reachable through the tool-surface filter', () => {
|
|
87
|
+
// The Linear gate (tool-filter.ts) must not strip it, with or without a
|
|
88
|
+
// Linear connection: an unregistered-in-effect tool is the same outage.
|
|
89
|
+
const schemas = bridgeToolSchemaNames().map((name) => ({ name }))
|
|
90
|
+
for (const linearEnabled of [false, true]) {
|
|
91
|
+
const names = buildEffectiveToolSchemas(schemas, { linearEnabled }).map((t) => t.name)
|
|
92
|
+
expect(names).toContain('progress_update')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -282,3 +282,35 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
282
282
|
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
283
283
|
})
|
|
284
284
|
})
|
|
285
|
+
|
|
286
|
+
describe('#3552 — onTurnKeyEnded disarms per-turn timers on bridge death', () => {
|
|
287
|
+
it('reports EVERY torn-down turn key, from both the controller loop and the dangling sweep', () => {
|
|
288
|
+
const { deps } = makeDeps('clerk')
|
|
289
|
+
// A third key that the controller loop does not cover — it only lives in
|
|
290
|
+
// activeTurnStartedAt (finalize already ran on the canonical reply path),
|
|
291
|
+
// so it is picked up by the dangling sweep.
|
|
292
|
+
deps.activeTurnStartedAt.set('chat3:thr3:msg3', 300)
|
|
293
|
+
const ended: string[] = []
|
|
294
|
+
|
|
295
|
+
flushOnAgentDisconnect({ ...deps, onTurnKeyEnded: (k: string) => ended.push(k) })
|
|
296
|
+
|
|
297
|
+
// Outcome: no turn key survives the flush without being reported, so no
|
|
298
|
+
// silence-poke state can outlive the dead bridge's turns.
|
|
299
|
+
expect(new Set(ended)).toEqual(
|
|
300
|
+
new Set(['chat1:thr1:msg1', 'chat2:thr2:msg2', 'chat3:thr3:msg3']),
|
|
301
|
+
)
|
|
302
|
+
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('is never called for an anonymous disconnect (no turns were owned)', () => {
|
|
306
|
+
const { deps } = makeDeps(null)
|
|
307
|
+
const ended: string[] = []
|
|
308
|
+
flushOnAgentDisconnect({ ...deps, onTurnKeyEnded: (k: string) => ended.push(k) })
|
|
309
|
+
expect(ended).toEqual([])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('omitting onTurnKeyEnded is safe (optional callback)', () => {
|
|
313
|
+
const { deps } = makeDeps('clerk')
|
|
314
|
+
expect(() => flushOnAgentDisconnect(deps)).not.toThrow()
|
|
315
|
+
})
|
|
316
|
+
})
|
|
@@ -333,6 +333,68 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
333
333
|
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
334
334
|
})
|
|
335
335
|
|
|
336
|
+
// ── #3544 ──────────────────────────────────────────────────────────────
|
|
337
|
+
// The 30 s orphan reap used to call `stopTypingLoop` unconditionally. That
|
|
338
|
+
// loop is keyed on (chat, thread), NOT on this seam's entry — so a handback
|
|
339
|
+
// turn still composing at 30 s had its indicator killed mid-turn, and nothing
|
|
340
|
+
// re-armed it. Outcome assertion: chat actions are still landing at 45 s.
|
|
341
|
+
it('#3544 — the orphan reap keeps typing alive when a real turn is live on the key (still emitting at 45s)', async () => {
|
|
342
|
+
vi.useFakeTimers()
|
|
343
|
+
try {
|
|
344
|
+
const liveKeys = new Set<string>()
|
|
345
|
+
const h = makeHarness({ hasLiveTurn: (k) => liveKeys.has(k) })
|
|
346
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatH', messageId: 900 }))
|
|
347
|
+
await h.sched.advance(700) // debounce fires: typing armed, card painted
|
|
348
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
349
|
+
|
|
350
|
+
// The handback's turn mints and is composing (a long one — worker results
|
|
351
|
+
// to read, a reply to write).
|
|
352
|
+
liveKeys.add('chatH:_')
|
|
353
|
+
const beforeReap = h.sendChatAction.mock.calls.length
|
|
354
|
+
|
|
355
|
+
await h.sched.advance(30_000) // the age-based orphan reap fires
|
|
356
|
+
// Pre-#3544 this was 0 and the chat went dark for the rest of the turn.
|
|
357
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
358
|
+
|
|
359
|
+
// …and it is genuinely still EMITTING (not merely registered) at 45 s.
|
|
360
|
+
vi.advanceTimersByTime(15_000) // 15 s of the loop's 4 s refresh
|
|
361
|
+
expect(h.sendChatAction.mock.calls.length).toBeGreaterThan(beforeReap + 2)
|
|
362
|
+
expect(h.sendChatAction).toHaveBeenLastCalledWith('chatH', null)
|
|
363
|
+
} finally {
|
|
364
|
+
vi.useRealTimers()
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('#3544 — the orphan reap still stops typing when NO turn is live (no leaked interval)', async () => {
|
|
369
|
+
const h = makeHarness({ hasLiveTurn: () => false })
|
|
370
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatI', messageId: 901 }))
|
|
371
|
+
await h.sched.advance(700)
|
|
372
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
373
|
+
await h.sched.advance(30_000)
|
|
374
|
+
expect(h.typing.activeCount()).toBe(0)
|
|
375
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('#3544 — handleReaped keeps typing alive for a live turn, stops it otherwise', async () => {
|
|
379
|
+
const liveKeys = new Set<string>()
|
|
380
|
+
const h = makeHarness({ hasLiveTurn: (k) => liveKeys.has(k) })
|
|
381
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatJ', messageId: 902 }))
|
|
382
|
+
await h.sched.advance(700)
|
|
383
|
+
const syntheticKey = [...h.records.keys()][0]!
|
|
384
|
+
expect(syntheticKey.startsWith(PRETURN_TURNKEY_PREFIX)).toBe(true)
|
|
385
|
+
liveKeys.add('chatJ:_')
|
|
386
|
+
h.signal.handleReaped(syntheticKey)
|
|
387
|
+
expect(h.typing.activeCount()).toBe(1) // live turn owns the stop
|
|
388
|
+
expect(h.signal.pendingCount()).toBe(0) // entry still dropped
|
|
389
|
+
|
|
390
|
+
// Same hook with no live turn DOES stop the loop.
|
|
391
|
+
const h2 = makeHarness({ hasLiveTurn: () => false })
|
|
392
|
+
h2.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatK', messageId: 903 }))
|
|
393
|
+
await h2.sched.advance(700)
|
|
394
|
+
h2.signal.handleReaped([...h2.records.keys()][0]!)
|
|
395
|
+
expect(h2.typing.activeCount()).toBe(0)
|
|
396
|
+
})
|
|
397
|
+
|
|
336
398
|
it('skips the emit when the adopting turn already settled before the debounce fires', async () => {
|
|
337
399
|
const settled = new Set<string>()
|
|
338
400
|
const h = makeHarness({ isTurnSettled: (k) => settled.has(k) })
|