switchroom 0.19.16 → 0.19.18
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 +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- 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 +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- 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/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- 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/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/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -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/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -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
|
+
})
|
|
@@ -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) })
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture for the REAL `buildSilencePokeOptions` wiring (#3575 review B3).
|
|
3
|
+
*
|
|
4
|
+
* The two highest-blast-radius pieces of `liveness-wiring.ts` — the `isTurnLive`
|
|
5
|
+
* reaper predicate and the `onFrameworkFallback` teardown + notice — had NO
|
|
6
|
+
* executing coverage: `buildSilencePokeOptions` has exactly one caller (the
|
|
7
|
+
* gateway's module-load `startTimer`) and no test imported it, so the suite
|
|
8
|
+
* either stubbed the predicate or asserted regexes against the function's own
|
|
9
|
+
* SOURCE TEXT. A hand-retyped copy of a predicate cannot detect divergence, and
|
|
10
|
+
* a source regex passes even if the handler is never invoked.
|
|
11
|
+
*
|
|
12
|
+
* `buildSilencePokeOptions` is a pure function of its deps object (its only
|
|
13
|
+
* gateway import is `import type`), so the whole options object CAN be built in
|
|
14
|
+
* a unit test. This fixture supplies the collaborators the fallback actually
|
|
15
|
+
* touches, with spies on the two observable effects (`sendSilenceText`,
|
|
16
|
+
* `endCurrentTurnForKey`).
|
|
17
|
+
*/
|
|
18
|
+
import { mkdtempSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import type { buildSilencePokeOptions } from '../../gateway/liveness-wiring.js'
|
|
22
|
+
|
|
23
|
+
type Deps = Parameters<typeof buildSilencePokeOptions>[0]
|
|
24
|
+
|
|
25
|
+
export interface SentText {
|
|
26
|
+
chatId: string
|
|
27
|
+
threadId: number | null
|
|
28
|
+
text: string
|
|
29
|
+
silent: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LivenessFixture {
|
|
33
|
+
deps: Deps
|
|
34
|
+
sent: SentText[]
|
|
35
|
+
endedKeys: string[]
|
|
36
|
+
/** The gateway's `activeTurnStartedAt` map, writable by the test. */
|
|
37
|
+
activeTurnStartedAt: Map<string, number>
|
|
38
|
+
/** Swap the "current turn" the wiring reads. */
|
|
39
|
+
setCurrentTurn: (t: unknown) => void
|
|
40
|
+
/**
|
|
41
|
+
* Start a turn FOR A TOPIC KEY. With a `turnMap` supplied (the flag-ON
|
|
42
|
+
* `CurrentTurnMap`), this is the gateway's `setCurrentTurn(turn, key)`: it
|
|
43
|
+
* writes the per-topic entry AND flips the most-recent-set mirror. Without
|
|
44
|
+
* one, it degrades to the flag-OFF singleton assignment.
|
|
45
|
+
*/
|
|
46
|
+
setCurrentTurnForKey: (key: string, t: unknown) => void
|
|
47
|
+
/** Open obligation ids the per-turn `representWillFollow` lookup consults. */
|
|
48
|
+
openObligations: Set<string>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The subset of the REAL `CurrentTurnMap` (gateway/current-turn-map.ts) the
|
|
53
|
+
* liveness wiring touches. #3580 tests pass a genuine instance, re-imported
|
|
54
|
+
* under `SWITCHROOM_EMISSION_AUTHORITY=1` via the module's read-once seam, so
|
|
55
|
+
* the flag-ON mirror-vs-keyed semantics under test are the production ones and
|
|
56
|
+
* not a hand-retyped model of them.
|
|
57
|
+
*/
|
|
58
|
+
export interface TurnMapLike {
|
|
59
|
+
get(key?: string): unknown
|
|
60
|
+
set(turn: never, key: string): void
|
|
61
|
+
isLiveForKey(turn: never, key: string): boolean
|
|
62
|
+
endTurnForKey(turn: never, key: string): boolean
|
|
63
|
+
purgeChatStale(chatId: string, isStale?: (key: string) => boolean): string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function statusKeyForTests(chatId: string, threadId: number | null | undefined): string {
|
|
67
|
+
return `${chatId}:${threadId ?? '_'}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal but REAL-shaped CurrentTurn for the fields the fallback reads. */
|
|
71
|
+
export function makeTurn(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
72
|
+
return {
|
|
73
|
+
sessionChatId: '-100999',
|
|
74
|
+
sessionThreadId: undefined,
|
|
75
|
+
turnId: '-100999#42',
|
|
76
|
+
registryKey: null,
|
|
77
|
+
role: 'user',
|
|
78
|
+
finalAnswerDelivered: false,
|
|
79
|
+
capturedText: [],
|
|
80
|
+
lastAssistantMsgId: null,
|
|
81
|
+
lastAssistantDone: false,
|
|
82
|
+
toolCallCount: 0,
|
|
83
|
+
...over,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function makeLivenessFixture(
|
|
88
|
+
over: Partial<Record<string, unknown>> = {},
|
|
89
|
+
turnMap?: TurnMapLike,
|
|
90
|
+
): LivenessFixture {
|
|
91
|
+
const sent: SentText[] = []
|
|
92
|
+
const endedKeys: string[] = []
|
|
93
|
+
const activeTurnStartedAt = new Map<string, number>()
|
|
94
|
+
const openObligations = new Set<string>()
|
|
95
|
+
let currentTurn: unknown = null
|
|
96
|
+
|
|
97
|
+
// Mirrors gateway.ts's `gatewayLivenessWiringDeps()` exactly. Without a
|
|
98
|
+
// `turnMap` the fixture models the flag-OFF gateway, where `currentTurnMap`
|
|
99
|
+
// holds only the singleton and the keyed read `currentTurnMap.get(key)`
|
|
100
|
+
// returns it — hence `getCurrentTurnForKey` ignoring the key here is the
|
|
101
|
+
// production flag-OFF behaviour, not a shortcut. With a `turnMap` it is the
|
|
102
|
+
// real keyed store and every accessor delegates to it.
|
|
103
|
+
const turnMapDeps = turnMap == null
|
|
104
|
+
? {
|
|
105
|
+
getCurrentTurn: () => currentTurn,
|
|
106
|
+
getCurrentTurnForKey: (_key: string) => currentTurn,
|
|
107
|
+
currentTurnMap: { purgeChatStale: () => {} },
|
|
108
|
+
turnLiveForItsTopic: () => true,
|
|
109
|
+
endCurrentTurnForKey: (_turn: unknown, key: string) => {
|
|
110
|
+
endedKeys.push(key)
|
|
111
|
+
currentTurn = null
|
|
112
|
+
return true
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
: {
|
|
116
|
+
getCurrentTurn: () => turnMap.get() ?? null,
|
|
117
|
+
getCurrentTurnForKey: (key: string) => turnMap.get(key) ?? null,
|
|
118
|
+
currentTurnMap: turnMap,
|
|
119
|
+
turnLiveForItsTopic: (turn: { sessionChatId: string; sessionThreadId?: number | null }) =>
|
|
120
|
+
turnMap.isLiveForKey(
|
|
121
|
+
turn as never,
|
|
122
|
+
statusKeyForTests(turn.sessionChatId, turn.sessionThreadId),
|
|
123
|
+
),
|
|
124
|
+
endCurrentTurnForKey: (turn: unknown, key: string) => {
|
|
125
|
+
endedKeys.push(key)
|
|
126
|
+
return turnMap.endTurnForKey(turn as never, key)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const deps = {
|
|
131
|
+
SILENCE_FALLBACK_MS: 300_000,
|
|
132
|
+
SILENCE_FALLBACK_HARD_MS: 900_000,
|
|
133
|
+
SILENCE_FLOOR_MS: 90_000,
|
|
134
|
+
SILENCE_DEFER_INFLIGHT_TOOLS: false,
|
|
135
|
+
isObligationOpenForTurn: (originTurnId: string | null): boolean =>
|
|
136
|
+
originTurnId != null && openObligations.has(originTurnId),
|
|
137
|
+
TURN_PREVIEW_MAX: 200,
|
|
138
|
+
STATE_DIR: mkdtempSync(join(tmpdir(), 'liveness-wiring-')),
|
|
139
|
+
isLegitimatelyWorking: () => false,
|
|
140
|
+
...turnMapDeps,
|
|
141
|
+
getInFlightUpdate: () => null,
|
|
142
|
+
getTurnsDb: () => null,
|
|
143
|
+
getInboundSpool: () => undefined,
|
|
144
|
+
sendToAgent: () => true,
|
|
145
|
+
sendSilenceText: async (chatId: string, threadId: number | null, text: string, silent: boolean) => {
|
|
146
|
+
sent.push({ chatId, threadId, text, silent })
|
|
147
|
+
},
|
|
148
|
+
getMyAgentName: () => 'test-agent',
|
|
149
|
+
statusKey: statusKeyForTests,
|
|
150
|
+
activeTurnStartedAt,
|
|
151
|
+
activeStatusReactions: new Map(),
|
|
152
|
+
lastPtyPreviewByChat: new Map(),
|
|
153
|
+
preambleSuppressor: { dropNow: () => {} },
|
|
154
|
+
purgeReactionTracking: () => {},
|
|
155
|
+
getPendingInboundBuffer: () => ({ drain: () => [] }),
|
|
156
|
+
trackRedeliveredInbound: () => {},
|
|
157
|
+
closeActivityLane: () => {},
|
|
158
|
+
closeProgressLane: () => {},
|
|
159
|
+
hangRestart: null,
|
|
160
|
+
...over,
|
|
161
|
+
} as unknown as Deps
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
deps,
|
|
165
|
+
sent,
|
|
166
|
+
endedKeys,
|
|
167
|
+
activeTurnStartedAt,
|
|
168
|
+
setCurrentTurn: (t: unknown) => {
|
|
169
|
+
if (turnMap != null) throw new Error('use setCurrentTurnForKey with a keyed turnMap')
|
|
170
|
+
currentTurn = t
|
|
171
|
+
},
|
|
172
|
+
setCurrentTurnForKey: (key: string, t: unknown) => {
|
|
173
|
+
if (turnMap == null) { currentTurn = t; return }
|
|
174
|
+
turnMap.set(t as never, key)
|
|
175
|
+
},
|
|
176
|
+
openObligations,
|
|
177
|
+
}
|
|
178
|
+
}
|