@swarmclawai/swarmclaw 1.5.65 → 1.5.67
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/README.md +16 -0
- package/package.json +1 -1
- package/src/components/agents/inspector-panel.tsx +11 -1
- package/src/components/chat/chat-list.tsx +5 -1
- package/src/components/chatrooms/chatroom-input.tsx +1 -1
- package/src/components/chatrooms/chatroom-view.tsx +145 -3
- package/src/lib/chatroom-sessions.ts +4 -0
- package/src/lib/server/chatrooms/chatroom-helpers.test.ts +7 -0
- package/src/lib/server/chatrooms/chatroom-helpers.ts +2 -1
- package/src/lib/server/runtime/heartbeat-service.test.ts +32 -0
- package/src/lib/server/runtime/heartbeat-service.ts +59 -25
- package/src/stores/slices/agent-slice.ts +3 -2
- package/src/stores/slices/session-slice.test.ts +52 -0
- package/src/stores/slices/session-slice.ts +16 -4
package/README.md
CHANGED
|
@@ -399,6 +399,22 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.67 Highlights
|
|
403
|
+
|
|
404
|
+
Three chatroom-focused fixes from a community contribution by [@borislavnnikolov](https://github.com/borislavnnikolov). Thanks Borislav!
|
|
405
|
+
|
|
406
|
+
- **Inspect a chatroom member mid-turn.** Clicking a member avatar in a chatroom while that agent is busy now opens a bottom sheet with the agent's synthetic chatroom session: recent messages, execution-log entries, and counts. Previously the click jumped to the agent detail page, which lost the chatroom context. The synthetic session-id convention (`chatroom-<roomId>-<agentId>`) is now centralized in `src/lib/chatroom-sessions.ts` and shared between the UI and `chatroom-helpers.ts` so the two halves can never drift.
|
|
407
|
+
- **Continue a specific session, not just the agent's main thread.** The store now exposes an `activeSessionIdOverride` on the session slice. Selecting a chat from the Chat List or a specific session row from the Agent Inspector sets the override, so the chat surface opens that exact session instead of the agent's primary thread session. The override clears automatically when the agent changes or the session is removed, with regression tests in `session-slice.test.ts` covering the override-preferred, override-stale, and fallback cases.
|
|
408
|
+
- **Caret stays aligned with mention highlights in the chatroom composer.** The mention-highlight `<span>` had `px-0.5` padding that pushed the mirrored caret out of position at line ends. Padding is removed and the soft-accent background lightened slightly so the highlight still reads without nudging the layout.
|
|
409
|
+
|
|
410
|
+
### v1.5.66 Highlights
|
|
411
|
+
|
|
412
|
+
Fixes a runaway-token-burn bug in the orchestrator-wake and heartbeat loops. The root cause was hidden in the success/failure classification: a session run can resolve its promise successfully while still carrying an `error` on the result (e.g. a provider 429 swallowed into persisted output), and the wake trackers only incremented their failure counters on a rejected promise. So the backoff never engaged, the auto-disable-after-N-failures gate never tripped, and the wake kept firing at its configured interval indefinitely — every firing spending tokens on a full prompt against a provider that was already cooling down.
|
|
413
|
+
|
|
414
|
+
- **`classifyWakeOutcome` (`src/lib/server/runtime/heartbeat-service.ts`)** — new pure helper, extracted for unit testing, that maps a resolved run result into `null` (success) or a short failure reason. A run counts as a failure when `result.error` is a non-empty string, *or* when `result.text` is empty/whitespace-only. Both the orchestrator-wake and heartbeat outcome handlers now feed through this helper, so silent-failure runs tick the failure counter and the exponential backoff (10s → 5min) kicks in normally.
|
|
415
|
+
- **Auto-disable gate now trips for provider 429 / silent-wake loops.** The existing `MAX_CONSECUTIVE_FAILURES = 10` threshold was already in place but unreachable for the most common failure mode (429 errors that still persisted a run). After the fix, ten consecutive dud wakes auto-disable the orchestrator/heartbeat for that agent/session and post an explicit notification instead of grinding indefinitely.
|
|
416
|
+
- **Regression coverage.** `heartbeat-service.test.ts` now has 5 targeted cases on `classifyWakeOutcome` — the 429 regression, empty-output detection, non-string error fields, whitespace-only errors, and the happy path. `test:runtime` now runs 104 cases.
|
|
417
|
+
|
|
402
418
|
### v1.5.65 Highlights
|
|
403
419
|
|
|
404
420
|
Follow-up hardening on the v1.5.64 work after live-testing the chat-header flows, the MCP connection pool, and the MCP Registry browser. Six concrete bugs fixed in the clear/undo, MCP pool eviction, and registry-browser code paths.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.67",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -903,6 +903,8 @@ function SessionsSection({ agent }: { agent: Agent }) {
|
|
|
903
903
|
const connectors = useAppStore((s) => s.connectors)
|
|
904
904
|
const agents = useAppStore((s) => s.agents)
|
|
905
905
|
const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
|
|
906
|
+
const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride)
|
|
907
|
+
const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
|
|
906
908
|
|
|
907
909
|
const agentSessions = useMemo(() => {
|
|
908
910
|
return Object.values(sessions).filter((s) => s.agentId === agent.id)
|
|
@@ -922,7 +924,15 @@ function SessionsSection({ agent }: { agent: Agent }) {
|
|
|
922
924
|
<button
|
|
923
925
|
key={s.id}
|
|
924
926
|
type="button"
|
|
925
|
-
onClick={() =>
|
|
927
|
+
onClick={() => {
|
|
928
|
+
void setCurrentAgent(agent.id).then(() => {
|
|
929
|
+
setActiveSessionIdOverride(s.id)
|
|
930
|
+
setInspectorOpen(false)
|
|
931
|
+
if (typeof window !== 'undefined') {
|
|
932
|
+
window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom'))
|
|
933
|
+
}
|
|
934
|
+
}).catch(() => {})
|
|
935
|
+
}}
|
|
926
936
|
className="flex items-center gap-2 w-full py-1.5 px-2 rounded-[8px] bg-transparent border-none cursor-pointer hover:bg-white/[0.04] transition-colors text-left"
|
|
927
937
|
>
|
|
928
938
|
{connector ? (
|
|
@@ -26,6 +26,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
26
26
|
const currentUser = useAppStore((s) => s.currentUser)
|
|
27
27
|
const currentSessionId = useAppStore(selectActiveSessionId)
|
|
28
28
|
const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
|
|
29
|
+
const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride)
|
|
29
30
|
const loadConnectors = useAppStore((s) => s.loadConnectors)
|
|
30
31
|
const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
|
|
31
32
|
const clearSessions = useAppStore((s) => s.clearSessions)
|
|
@@ -117,7 +118,10 @@ export function ChatList({ inSidebar, onSelect }: Props) {
|
|
|
117
118
|
|
|
118
119
|
const handleSelect = async (id: string) => {
|
|
119
120
|
const agentId = sessions[id]?.agentId
|
|
120
|
-
if (agentId)
|
|
121
|
+
if (agentId) {
|
|
122
|
+
await setCurrentAgent(agentId)
|
|
123
|
+
setActiveSessionIdOverride(id)
|
|
124
|
+
}
|
|
121
125
|
markChatRead(id)
|
|
122
126
|
if (typeof window !== 'undefined') {
|
|
123
127
|
window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom'))
|
|
@@ -176,7 +176,7 @@ export function ChatroomInput({ agents, onSend, disabled, onBreakoutRequest }: P
|
|
|
176
176
|
parts.push(text.slice(lastIndex, match.index))
|
|
177
177
|
}
|
|
178
178
|
parts.push(
|
|
179
|
-
<span key={match.index} className="bg-accent-soft/
|
|
179
|
+
<span key={match.index} className="bg-accent-soft/45 text-accent-bright rounded">
|
|
180
180
|
{match[0]}
|
|
181
181
|
</span>
|
|
182
182
|
)
|
|
@@ -9,6 +9,7 @@ import { useNavigate } from '@/lib/app/navigation'
|
|
|
9
9
|
import { useNow } from '@/hooks/use-now'
|
|
10
10
|
import { useWs } from '@/hooks/use-ws'
|
|
11
11
|
import { api } from '@/lib/app/api-client'
|
|
12
|
+
import { resolveChatroomSyntheticSessionId } from '@/lib/chatroom-sessions'
|
|
12
13
|
import { ChatroomMessageBubble } from './chatroom-message'
|
|
13
14
|
import { ChatroomInput } from './chatroom-input'
|
|
14
15
|
import { ChatroomTypingBar } from './chatroom-typing-bar'
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
StructuredSessionLauncher,
|
|
21
22
|
type StructuredSessionLaunchContext,
|
|
22
23
|
} from '@/components/protocols/structured-session-launcher'
|
|
23
|
-
import type { Chatroom, ChatroomMessage, ChatroomMember, Agent, ProtocolRun } from '@/types'
|
|
24
|
+
import type { Chatroom, ChatroomMessage, ChatroomMember, Agent, ProtocolRun, Session, Message } from '@/types'
|
|
24
25
|
|
|
25
26
|
function getRoleBadge(role: string) {
|
|
26
27
|
if (role === 'admin') return { label: 'Admin', className: 'bg-purple-500/20 text-purple-400 border-purple-500/30' }
|
|
@@ -45,6 +46,13 @@ function isAgentMuted(chatroom: Chatroom, agentId: string, now: number | null):
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
|
|
49
|
+
type SessionExecLogEntry = {
|
|
50
|
+
id: string
|
|
51
|
+
category: string
|
|
52
|
+
summary: string
|
|
53
|
+
detail: Record<string, unknown> | null
|
|
54
|
+
ts: number
|
|
55
|
+
}
|
|
48
56
|
|
|
49
57
|
function useAgentHeartbeat(agentId: string, onPulse: (id: string) => void) {
|
|
50
58
|
const topic = agentId ? `heartbeat:agent:${agentId}` : ''
|
|
@@ -120,6 +128,13 @@ export function ChatroomView() {
|
|
|
120
128
|
const [injectError, setInjectError] = useState<string | null>(null)
|
|
121
129
|
const [linkedRun, setLinkedRun] = useState<ProtocolRun | null>(null)
|
|
122
130
|
const [activeParentRun, setActiveParentRun] = useState<ProtocolRun | null>(null)
|
|
131
|
+
const [inspectSessionOpen, setInspectSessionOpen] = useState(false)
|
|
132
|
+
const [inspectedAgent, setInspectedAgent] = useState<Agent | null>(null)
|
|
133
|
+
const [inspectedSessionId, setInspectedSessionId] = useState<string | null>(null)
|
|
134
|
+
const [inspectedMessages, setInspectedMessages] = useState<Message[]>([])
|
|
135
|
+
const [inspectedExecLogs, setInspectedExecLogs] = useState<SessionExecLogEntry[]>([])
|
|
136
|
+
const [inspectLoading, setInspectLoading] = useState(false)
|
|
137
|
+
const [inspectError, setInspectError] = useState<string | null>(null)
|
|
123
138
|
|
|
124
139
|
const handleHeartbeatPulse = useCallback((agentId: string) => {
|
|
125
140
|
setAgentMoments((prev) => ({ ...prev, [agentId]: { kind: 'heartbeat' } }))
|
|
@@ -227,6 +242,8 @@ export function ChatroomView() {
|
|
|
227
242
|
|
|
228
243
|
useEffect(() => {
|
|
229
244
|
setDetailsOpen(false)
|
|
245
|
+
setInspectSessionOpen(false)
|
|
246
|
+
setInspectError(null)
|
|
230
247
|
}, [chatroomId])
|
|
231
248
|
|
|
232
249
|
const refreshLinkedRun = useCallback(() => {
|
|
@@ -330,6 +347,29 @@ export function ChatroomView() {
|
|
|
330
347
|
}
|
|
331
348
|
}
|
|
332
349
|
|
|
350
|
+
const handleInspectAgentSession = async (agent: Agent) => {
|
|
351
|
+
if (!chatroom) return
|
|
352
|
+
const sessionId = resolveChatroomSyntheticSessionId(chatroom.id, agent.id)
|
|
353
|
+
setInspectSessionOpen(true)
|
|
354
|
+
setInspectedAgent(agent)
|
|
355
|
+
setInspectedSessionId(sessionId)
|
|
356
|
+
setInspectLoading(true)
|
|
357
|
+
setInspectError(null)
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const session = await api<Session>('GET', `/chats/${encodeURIComponent(sessionId)}`)
|
|
361
|
+
setInspectedMessages(Array.isArray(session?.messages) ? session.messages : [])
|
|
362
|
+
const logs = await api<SessionExecLogEntry[]>('GET', `/chats/${encodeURIComponent(sessionId)}/execution-log?limit=200`)
|
|
363
|
+
setInspectedExecLogs(Array.isArray(logs) ? logs : [])
|
|
364
|
+
} catch (error) {
|
|
365
|
+
setInspectedMessages([])
|
|
366
|
+
setInspectedExecLogs([])
|
|
367
|
+
setInspectError(error instanceof Error ? error.message : 'Unable to load this agent session yet.')
|
|
368
|
+
} finally {
|
|
369
|
+
setInspectLoading(false)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
333
373
|
return (
|
|
334
374
|
<div className="flex-1 flex min-h-0 min-w-0">
|
|
335
375
|
<div className="min-w-0 flex-1 flex flex-col h-full">
|
|
@@ -379,7 +419,13 @@ export function ChatroomView() {
|
|
|
379
419
|
<Tooltip key={agent.id}>
|
|
380
420
|
<TooltipTrigger asChild>
|
|
381
421
|
<button
|
|
382
|
-
onClick={() =>
|
|
422
|
+
onClick={() => {
|
|
423
|
+
if (streamingAgents.has(agent.id)) {
|
|
424
|
+
void handleInspectAgentSession(agent)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
navigateToAgent(agent.id)
|
|
428
|
+
}}
|
|
383
429
|
className={`relative transition-all duration-200 hover:scale-110 hover:z-10 hover:-translate-y-0.5 cursor-pointer bg-transparent border-none p-0 ${muted ? 'opacity-40' : ''}`}
|
|
384
430
|
>
|
|
385
431
|
<AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={22} status={streamingAgents.has(agent.id) ? 'busy' : 'online'} />
|
|
@@ -393,6 +439,7 @@ export function ChatroomView() {
|
|
|
393
439
|
<TooltipContent side="bottom" sideOffset={6}>
|
|
394
440
|
<div className="flex items-center gap-1.5">
|
|
395
441
|
<span>{agent.name}</span>
|
|
442
|
+
{streamingAgents.has(agent.id) && <span className="text-[9px] text-sky-300">Click to inspect</span>}
|
|
396
443
|
{badge && <span className={`text-[9px] font-600 px-1 py-0.5 rounded border ${badge.className}`}>{badge.label}</span>}
|
|
397
444
|
{muted && <span className="text-[9px] text-red-400">Muted</span>}
|
|
398
445
|
</div>
|
|
@@ -635,6 +682,7 @@ export function ChatroomView() {
|
|
|
635
682
|
now={now}
|
|
636
683
|
onFocusMessage={focusMessage}
|
|
637
684
|
onNavigateToAgent={navigateToAgent}
|
|
685
|
+
onInspectAgentSession={handleInspectAgentSession}
|
|
638
686
|
/>
|
|
639
687
|
</aside>
|
|
640
688
|
|
|
@@ -652,9 +700,95 @@ export function ChatroomView() {
|
|
|
652
700
|
setTimeout(() => focusMessage(messageId), 50)
|
|
653
701
|
}}
|
|
654
702
|
onNavigateToAgent={navigateToAgent}
|
|
703
|
+
onInspectAgentSession={handleInspectAgentSession}
|
|
655
704
|
compact
|
|
656
705
|
/>
|
|
657
706
|
</BottomSheet>
|
|
707
|
+
<BottomSheet
|
|
708
|
+
open={inspectSessionOpen}
|
|
709
|
+
onClose={() => {
|
|
710
|
+
setInspectSessionOpen(false)
|
|
711
|
+
setInspectError(null)
|
|
712
|
+
}}
|
|
713
|
+
title={inspectedAgent ? `${inspectedAgent.name} session` : 'Agent session'}
|
|
714
|
+
description={inspectedSessionId ? `Inspecting ${inspectedSessionId}` : 'Inspecting active chatroom member session'}
|
|
715
|
+
>
|
|
716
|
+
{inspectLoading ? (
|
|
717
|
+
<div className="rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-4 py-6 text-[13px] text-text-3">
|
|
718
|
+
Loading session activity…
|
|
719
|
+
</div>
|
|
720
|
+
) : inspectError ? (
|
|
721
|
+
<div className="rounded-[12px] border border-red-500/20 bg-red-500/10 px-4 py-3 text-[13px] text-red-200">
|
|
722
|
+
{inspectError}
|
|
723
|
+
</div>
|
|
724
|
+
) : (
|
|
725
|
+
<div className="space-y-4">
|
|
726
|
+
<div className="grid grid-cols-3 gap-2">
|
|
727
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
728
|
+
<div className="text-[16px] font-display font-700 text-text">{inspectedMessages.length}</div>
|
|
729
|
+
<div className="mt-0.5 text-[10px] uppercase tracking-[0.08em] text-text-3/50">Messages</div>
|
|
730
|
+
</div>
|
|
731
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
732
|
+
<div className="text-[16px] font-display font-700 text-sky-300">{inspectedExecLogs.length}</div>
|
|
733
|
+
<div className="mt-0.5 text-[10px] uppercase tracking-[0.08em] text-text-3/50">Events</div>
|
|
734
|
+
</div>
|
|
735
|
+
<div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
|
736
|
+
<div className="text-[12px] font-mono text-text-2 truncate" title={inspectedSessionId || undefined}>
|
|
737
|
+
{inspectedSessionId || '—'}
|
|
738
|
+
</div>
|
|
739
|
+
<div className="mt-0.5 text-[10px] uppercase tracking-[0.08em] text-text-3/50">Session ID</div>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<section>
|
|
744
|
+
<h4 className="mb-2 text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Recent Messages</h4>
|
|
745
|
+
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
|
|
746
|
+
{inspectedMessages.length === 0 ? (
|
|
747
|
+
<p className="text-[12px] text-text-3">No messages yet for this session.</p>
|
|
748
|
+
) : (
|
|
749
|
+
inspectedMessages.slice(-12).map((message, index) => (
|
|
750
|
+
<div key={`${message.time}-${message.role}-${index}`} className="rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-2">
|
|
751
|
+
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.08em] text-text-3/60">
|
|
752
|
+
<span>{message.role}</span>
|
|
753
|
+
<span>·</span>
|
|
754
|
+
<span>{new Date(message.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
|
|
755
|
+
</div>
|
|
756
|
+
<p className="mt-1 text-[12px] leading-[1.5] text-text-2 whitespace-pre-wrap break-words">
|
|
757
|
+
{message.text || '(empty)'}
|
|
758
|
+
</p>
|
|
759
|
+
</div>
|
|
760
|
+
))
|
|
761
|
+
)}
|
|
762
|
+
</div>
|
|
763
|
+
</section>
|
|
764
|
+
|
|
765
|
+
<section>
|
|
766
|
+
<h4 className="mb-2 text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Execution Log</h4>
|
|
767
|
+
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
|
|
768
|
+
{inspectedExecLogs.length === 0 ? (
|
|
769
|
+
<p className="text-[12px] text-text-3">No execution log entries yet.</p>
|
|
770
|
+
) : (
|
|
771
|
+
inspectedExecLogs
|
|
772
|
+
.slice()
|
|
773
|
+
.sort((a, b) => b.ts - a.ts)
|
|
774
|
+
.map((entry) => (
|
|
775
|
+
<div key={entry.id} className="rounded-[10px] border border-white/[0.05] bg-black/20 px-3 py-2">
|
|
776
|
+
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.08em] text-text-3/60">
|
|
777
|
+
<span>{entry.category}</span>
|
|
778
|
+
<span>·</span>
|
|
779
|
+
<span>{new Date(entry.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>
|
|
780
|
+
</div>
|
|
781
|
+
<p className="mt-1 text-[12px] leading-[1.5] text-text-2 whitespace-pre-wrap break-words">
|
|
782
|
+
{entry.summary}
|
|
783
|
+
</p>
|
|
784
|
+
</div>
|
|
785
|
+
))
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
</section>
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
</BottomSheet>
|
|
658
792
|
<StructuredSessionLauncher
|
|
659
793
|
open={structuredSessionOpen}
|
|
660
794
|
onClose={() => {
|
|
@@ -721,6 +855,7 @@ function RoomDetailsPanel({
|
|
|
721
855
|
now,
|
|
722
856
|
onFocusMessage,
|
|
723
857
|
onNavigateToAgent,
|
|
858
|
+
onInspectAgentSession,
|
|
724
859
|
compact = false,
|
|
725
860
|
}: {
|
|
726
861
|
chatroom: Chatroom
|
|
@@ -732,6 +867,7 @@ function RoomDetailsPanel({
|
|
|
732
867
|
now: number | null
|
|
733
868
|
onFocusMessage: (messageId: string) => void
|
|
734
869
|
onNavigateToAgent: (agentId: string) => void
|
|
870
|
+
onInspectAgentSession: (agent: Agent) => Promise<void>
|
|
735
871
|
compact?: boolean
|
|
736
872
|
}) {
|
|
737
873
|
return (
|
|
@@ -771,7 +907,13 @@ function RoomDetailsPanel({
|
|
|
771
907
|
return (
|
|
772
908
|
<button
|
|
773
909
|
key={agent.id}
|
|
774
|
-
onClick={() =>
|
|
910
|
+
onClick={() => {
|
|
911
|
+
if (streamingAgents.has(agent.id)) {
|
|
912
|
+
void onInspectAgentSession(agent)
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
onNavigateToAgent(agent.id)
|
|
916
|
+
}}
|
|
775
917
|
className="w-full rounded-[12px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5 text-left hover:bg-white/[0.05] transition-all cursor-pointer"
|
|
776
918
|
style={{ fontFamily: 'inherit' }}
|
|
777
919
|
>
|
|
@@ -10,10 +10,12 @@ import {
|
|
|
10
10
|
buildHistoryForAgent,
|
|
11
11
|
buildSyntheticSession,
|
|
12
12
|
resolveChatroomWorkspaceDir,
|
|
13
|
+
resolveSyntheticSessionId,
|
|
13
14
|
resolveAgentApiEndpoint,
|
|
14
15
|
resolveReplyTargetAgentId,
|
|
15
16
|
buildAgentSystemPromptForChatroom,
|
|
16
17
|
} from '@/lib/server/chatrooms/chatroom-helpers'
|
|
18
|
+
import { resolveChatroomSyntheticSessionId } from '@/lib/chatroom-sessions'
|
|
17
19
|
|
|
18
20
|
function makeAgents(): Record<string, Agent> {
|
|
19
21
|
const now = Date.now()
|
|
@@ -234,6 +236,11 @@ describe('chatroom-helpers', () => {
|
|
|
234
236
|
assert.match(cwd, /chatrooms[\/\\]room-safe$/)
|
|
235
237
|
})
|
|
236
238
|
|
|
239
|
+
it('uses a stable synthetic session id convention shared with the UI', () => {
|
|
240
|
+
assert.equal(resolveChatroomSyntheticSessionId('room-1', 'agent-1'), 'chatroom-room-1-agent-1')
|
|
241
|
+
assert.equal(resolveSyntheticSessionId('room-1', 'agent-1'), 'chatroom-room-1-agent-1')
|
|
242
|
+
})
|
|
243
|
+
|
|
237
244
|
it('matches multi-word agent name over shorter prefix', () => {
|
|
238
245
|
const agents = makeMultiWordAgents()
|
|
239
246
|
const memberIds = ['hal2k', 'hal2k-openai', 'code-monkey']
|
|
@@ -13,6 +13,7 @@ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/serve
|
|
|
13
13
|
import { loadSkills } from '@/lib/server/skills/skill-repository'
|
|
14
14
|
import { loadSession, patchSession, saveSession } from '@/lib/server/sessions/session-repository'
|
|
15
15
|
import { appendMessage } from '@/lib/server/messages/message-repository'
|
|
16
|
+
import { resolveChatroomSyntheticSessionId } from '@/lib/chatroom-sessions'
|
|
16
17
|
import type { Chatroom, ChatroomMember, Agent, Session, Message, ChatroomMessage } from '@/types'
|
|
17
18
|
import { getEnabledCapabilityIds, getEnabledToolIds } from '@/lib/capability-selection'
|
|
18
19
|
|
|
@@ -272,7 +273,7 @@ export function resolveChatroomWorkspaceDir(chatroomId: string): string {
|
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
export function resolveSyntheticSessionId(chatroomId: string, agentId: string): string {
|
|
275
|
-
return
|
|
276
|
+
return resolveChatroomSyntheticSessionId(chatroomId, agentId)
|
|
276
277
|
}
|
|
277
278
|
|
|
278
279
|
function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
|
|
@@ -450,3 +450,35 @@ describe('heartbeatConfigForSession lightContext', () => {
|
|
|
450
450
|
assert.equal(cfg.lightContext, false)
|
|
451
451
|
})
|
|
452
452
|
})
|
|
453
|
+
|
|
454
|
+
describe('classifyWakeOutcome (runaway-loop guard)', () => {
|
|
455
|
+
it('returns null for a run with visible text and no error', () => {
|
|
456
|
+
assert.equal(mod.classifyWakeOutcome({ text: 'all good', error: null }), null)
|
|
457
|
+
assert.equal(mod.classifyWakeOutcome({ text: 'ORCHESTRATOR_OK' }), null)
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('treats a resolved-but-errored result as failure (the 429 regression)', () => {
|
|
461
|
+
const out = mod.classifyWakeOutcome({
|
|
462
|
+
text: '',
|
|
463
|
+
error: '429 All credentials for model gpt-5.4 are cooling down via provider codex',
|
|
464
|
+
})
|
|
465
|
+
assert.equal(out, '429 All credentials for model gpt-5.4 are cooling down via provider codex')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('counts empty visible output as failure so silent wakes trigger backoff', () => {
|
|
469
|
+
assert.equal(mod.classifyWakeOutcome({ text: '' }), 'empty wake response')
|
|
470
|
+
assert.equal(mod.classifyWakeOutcome({ text: ' \n\t' }), 'empty wake response')
|
|
471
|
+
assert.equal(mod.classifyWakeOutcome({}), 'empty wake response')
|
|
472
|
+
assert.equal(mod.classifyWakeOutcome(null), 'empty wake response')
|
|
473
|
+
assert.equal(mod.classifyWakeOutcome(undefined), 'empty wake response')
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('ignores a non-string error field and falls back to text check', () => {
|
|
477
|
+
assert.equal(mod.classifyWakeOutcome({ text: 'hi', error: 42 }), null)
|
|
478
|
+
assert.equal(mod.classifyWakeOutcome({ text: '', error: 42 }), 'empty wake response')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('ignores an empty-string error so whitespace errors do not double-count', () => {
|
|
482
|
+
assert.equal(mod.classifyWakeOutcome({ text: 'fine', error: ' ' }), null)
|
|
483
|
+
})
|
|
484
|
+
})
|
|
@@ -54,6 +54,23 @@ const ORCHESTRATOR_MIN_INTERVAL_SEC = 60
|
|
|
54
54
|
const ORCHESTRATOR_MAX_INTERVAL_SEC = 86400 // 24h
|
|
55
55
|
const ORCHESTRATOR_MAX_PROMPT_CHARS = 4000
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Classify a resolved session-run result as success or failure for the
|
|
59
|
+
* heartbeat/orchestrator outcome tracker. A resolved promise can still
|
|
60
|
+
* carry an error on `result.error` (e.g. a provider 429 that was swallowed
|
|
61
|
+
* into persisted output) or resolve with empty text, and both cases must
|
|
62
|
+
* count as failures — otherwise a stuck wake loop never ticks the
|
|
63
|
+
* failure counter, never backs off, and never auto-disables.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyWakeOutcome(result: unknown): string | null {
|
|
66
|
+
if (!result || typeof result !== 'object') return 'empty wake response'
|
|
67
|
+
const obj = result as { error?: unknown; text?: unknown }
|
|
68
|
+
if (typeof obj.error === 'string' && obj.error.trim()) return obj.error
|
|
69
|
+
const text = typeof obj.text === 'string' ? obj.text : ''
|
|
70
|
+
if (!text.trim()) return 'empty wake response'
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
interface FailureRecord {
|
|
58
75
|
count: number
|
|
59
76
|
lastFailedAt: number
|
|
@@ -782,24 +799,28 @@ export async function tickHeartbeats() {
|
|
|
782
799
|
state.lastBySession.set(session.id, now)
|
|
783
800
|
|
|
784
801
|
const sid = session.id as string
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
802
|
+
// A session run can "resolve" with an error in result.error (e.g. provider
|
|
803
|
+
// 429 swallowed into the persisted failure) or with empty text. Treat both
|
|
804
|
+
// as failures so backoff and auto-disable trigger, otherwise a stuck
|
|
805
|
+
// heartbeat keeps re-firing at the configured interval and burning tokens.
|
|
806
|
+
const handleHeartbeatOutcome = (failure: string | null) => {
|
|
807
|
+
if (!failure) {
|
|
808
|
+
const prev = state.failures.get(sid)
|
|
809
|
+
if (prev?.recoveryAttempts) {
|
|
810
|
+
log.info('heartbeat', `Recovery successful for session ${sid} after ${prev.recoveryAttempts} attempt(s)`)
|
|
811
|
+
}
|
|
812
|
+
state.failures.delete(sid)
|
|
813
|
+
patchSession(sid, (s) => {
|
|
814
|
+
if (!s) return s
|
|
815
|
+
s.lastDeliveryStatus = 'ok'
|
|
816
|
+
s.lastDeliveredAt = Date.now()
|
|
817
|
+
return s
|
|
818
|
+
})
|
|
819
|
+
return
|
|
789
820
|
}
|
|
790
|
-
state.failures.delete(sid)
|
|
791
|
-
// Track successful delivery
|
|
792
|
-
patchSession(sid, (s) => {
|
|
793
|
-
if (!s) return s
|
|
794
|
-
s.lastDeliveryStatus = 'ok'
|
|
795
|
-
s.lastDeliveredAt = Date.now()
|
|
796
|
-
return s
|
|
797
|
-
})
|
|
798
|
-
}).catch((err: unknown) => {
|
|
799
821
|
const prev = state.failures.get(sid)
|
|
800
822
|
const newCount = (prev?.count ?? 0) + 1
|
|
801
823
|
const record: FailureRecord = { count: newCount, lastFailedAt: Date.now() }
|
|
802
|
-
// Auto-disable heartbeat after too many consecutive failures to prevent resource waste
|
|
803
824
|
if (newCount >= MAX_CONSECUTIVE_FAILURES) {
|
|
804
825
|
record.autoDisabledAt = Date.now()
|
|
805
826
|
log.warn('heartbeat', `Auto-disabling heartbeat for session ${sid} after ${newCount} consecutive failures`)
|
|
@@ -821,17 +842,20 @@ export async function tickHeartbeats() {
|
|
|
821
842
|
})
|
|
822
843
|
}
|
|
823
844
|
state.failures.set(sid, record)
|
|
824
|
-
|
|
825
|
-
log.warn('heartbeat', `Heartbeat run failed for session ${sid} (${newCount}/${MAX_CONSECUTIVE_FAILURES})`, msg)
|
|
826
|
-
// Track failed delivery
|
|
845
|
+
log.warn('heartbeat', `Heartbeat run failed for session ${sid} (${newCount}/${MAX_CONSECUTIVE_FAILURES})`, failure)
|
|
827
846
|
patchSession(sid, (s) => {
|
|
828
847
|
if (!s) return s
|
|
829
848
|
s.lastDeliveryStatus = 'error'
|
|
830
|
-
s.lastDeliveryError =
|
|
849
|
+
s.lastDeliveryError = failure
|
|
831
850
|
s.lastDeliveredAt = Date.now()
|
|
832
851
|
return s
|
|
833
852
|
})
|
|
834
|
-
}
|
|
853
|
+
}
|
|
854
|
+
enqueue.promise
|
|
855
|
+
.then((result) => handleHeartbeatOutcome(classifyWakeOutcome(result)))
|
|
856
|
+
.catch((err: unknown) => {
|
|
857
|
+
handleHeartbeatOutcome(errorMessage(err) || 'heartbeat rejected')
|
|
858
|
+
})
|
|
835
859
|
}
|
|
836
860
|
}
|
|
837
861
|
|
|
@@ -1118,10 +1142,15 @@ export async function tickOrchestratorAgents() {
|
|
|
1118
1142
|
|
|
1119
1143
|
log.info('orchestrator', `Woke orchestrator agent ${agent.name} (${agent.id}), cycle #${(agent.orchestratorCycleCount || 0) + 1}`)
|
|
1120
1144
|
|
|
1121
|
-
// Track success/failure
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1145
|
+
// Track success/failure. A run can "resolve" but still carry an error
|
|
1146
|
+
// on the result (e.g. provider 429 that was caught and persisted), so we
|
|
1147
|
+
// inspect the resolved result as well as the rejected path — otherwise
|
|
1148
|
+
// a stuck wake loop never ticks the failure counter and never backs off.
|
|
1149
|
+
const handleWakeOutcome = (failure: string | null) => {
|
|
1150
|
+
if (!failure) {
|
|
1151
|
+
orchestratorState.failures.delete(agent.id)
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1125
1154
|
const prev = orchestratorState.failures.get(agent.id)
|
|
1126
1155
|
const newCount = (prev?.count ?? 0) + 1
|
|
1127
1156
|
const record: FailureRecord = { count: newCount, lastFailedAt: Date.now() }
|
|
@@ -1146,8 +1175,13 @@ export async function tickOrchestratorAgents() {
|
|
|
1146
1175
|
})
|
|
1147
1176
|
}
|
|
1148
1177
|
orchestratorState.failures.set(agent.id, record)
|
|
1149
|
-
log.warn('orchestrator', `Orchestrator wake failed for agent ${agent.id} (${newCount}/${MAX_CONSECUTIVE_FAILURES})`,
|
|
1150
|
-
}
|
|
1178
|
+
log.warn('orchestrator', `Orchestrator wake failed for agent ${agent.id} (${newCount}/${MAX_CONSECUTIVE_FAILURES})`, failure)
|
|
1179
|
+
}
|
|
1180
|
+
enqueue.promise
|
|
1181
|
+
.then((result) => handleWakeOutcome(classifyWakeOutcome(result)))
|
|
1182
|
+
.catch((err: unknown) => {
|
|
1183
|
+
handleWakeOutcome(errorMessage(err) || 'wake rejected')
|
|
1184
|
+
})
|
|
1151
1185
|
} catch (err) {
|
|
1152
1186
|
log.warn('orchestrator', `Error ticking orchestrator agent ${agent.id}:`, errorMessage(err))
|
|
1153
1187
|
}
|
|
@@ -27,14 +27,15 @@ export const createAgentSlice: StateCreator<AppState, [], [], AgentSlice> = (set
|
|
|
27
27
|
currentAgentId: null,
|
|
28
28
|
setCurrentAgent: async (id) => {
|
|
29
29
|
if (!id) {
|
|
30
|
-
set({ currentAgentId: null })
|
|
30
|
+
set({ currentAgentId: null, activeSessionIdOverride: null })
|
|
31
31
|
safeStorageRemove('sc_agent')
|
|
32
32
|
return
|
|
33
33
|
}
|
|
34
34
|
if (get().currentAgentId === id && get().agents[id]?.threadSessionId) {
|
|
35
|
+
set({ activeSessionIdOverride: null })
|
|
35
36
|
return
|
|
36
37
|
}
|
|
37
|
-
set({ currentAgentId: id })
|
|
38
|
+
set({ currentAgentId: id, activeSessionIdOverride: null })
|
|
38
39
|
safeStorageSet('sc_agent', id)
|
|
39
40
|
|
|
40
41
|
await agentThreadDedup.dedup(id, async () => {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { Agent, Session } from '../../types'
|
|
4
|
+
import type { AppState } from '../use-app-store'
|
|
5
|
+
import { selectActiveSessionId } from './session-slice'
|
|
6
|
+
|
|
7
|
+
function makeState(overrides: Partial<AppState>): AppState {
|
|
8
|
+
return {
|
|
9
|
+
currentAgentId: null,
|
|
10
|
+
agents: {},
|
|
11
|
+
sessions: {},
|
|
12
|
+
activeSessionIdOverride: null,
|
|
13
|
+
...overrides,
|
|
14
|
+
} as AppState
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeAgent(id: string, threadSessionId: string): Agent {
|
|
18
|
+
return { id, threadSessionId } as unknown as Agent
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeSession(id: string): Session {
|
|
22
|
+
return { id } as unknown as Session
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('selectActiveSessionId prefers override when present', () => {
|
|
26
|
+
const state = makeState({
|
|
27
|
+
currentAgentId: 'agent-1',
|
|
28
|
+
agents: { 'agent-1': makeAgent('agent-1', 'thread-1') },
|
|
29
|
+
sessions: { 'thread-1': makeSession('thread-1'), 'task-1': makeSession('task-1') },
|
|
30
|
+
activeSessionIdOverride: 'task-1',
|
|
31
|
+
})
|
|
32
|
+
assert.equal(selectActiveSessionId(state), 'task-1')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('selectActiveSessionId falls back to agent thread session', () => {
|
|
36
|
+
const state = makeState({
|
|
37
|
+
currentAgentId: 'agent-1',
|
|
38
|
+
agents: { 'agent-1': makeAgent('agent-1', 'thread-1') },
|
|
39
|
+
sessions: { 'thread-1': makeSession('thread-1') },
|
|
40
|
+
})
|
|
41
|
+
assert.equal(selectActiveSessionId(state), 'thread-1')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('selectActiveSessionId ignores stale override ids', () => {
|
|
45
|
+
const state = makeState({
|
|
46
|
+
currentAgentId: 'agent-1',
|
|
47
|
+
agents: { 'agent-1': makeAgent('agent-1', 'thread-1') },
|
|
48
|
+
sessions: { 'thread-1': makeSession('thread-1') },
|
|
49
|
+
activeSessionIdOverride: 'missing-session',
|
|
50
|
+
})
|
|
51
|
+
assert.equal(selectActiveSessionId(state), 'thread-1')
|
|
52
|
+
})
|
|
@@ -10,6 +10,9 @@ const sessionRefreshDedup = createInflightDeduplicator('sessionSlice_inflightRef
|
|
|
10
10
|
|
|
11
11
|
/** Derive the active session ID from the current agent — no stored `currentSessionId`. */
|
|
12
12
|
export function selectActiveSessionId(s: AppState): string | null {
|
|
13
|
+
if (s.activeSessionIdOverride && s.sessions[s.activeSessionIdOverride]) {
|
|
14
|
+
return s.activeSessionIdOverride
|
|
15
|
+
}
|
|
13
16
|
if (!s.currentAgentId) return null
|
|
14
17
|
const agent = s.agents[s.currentAgentId]
|
|
15
18
|
return agent?.threadSessionId ?? null
|
|
@@ -17,6 +20,8 @@ export function selectActiveSessionId(s: AppState): string | null {
|
|
|
17
20
|
|
|
18
21
|
export interface SessionSlice {
|
|
19
22
|
sessions: Sessions
|
|
23
|
+
activeSessionIdOverride: string | null
|
|
24
|
+
setActiveSessionIdOverride: (id: string | null) => void
|
|
20
25
|
loadSessions: () => Promise<void>
|
|
21
26
|
refreshSession: (id: string) => Promise<void>
|
|
22
27
|
removeSession: (id: string) => void
|
|
@@ -27,6 +32,8 @@ export interface SessionSlice {
|
|
|
27
32
|
|
|
28
33
|
export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> = (set, get) => ({
|
|
29
34
|
sessions: {},
|
|
35
|
+
activeSessionIdOverride: null,
|
|
36
|
+
setActiveSessionIdOverride: (id) => set({ activeSessionIdOverride: id }),
|
|
30
37
|
loadSessions: createLoader<AppState>(set, 'sessions', () => fetchChats()),
|
|
31
38
|
refreshSession: async (id) => {
|
|
32
39
|
if (!id) return
|
|
@@ -51,9 +58,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|
|
51
58
|
invalidateFingerprint('sessions')
|
|
52
59
|
const activeSessionId = selectActiveSessionId(get())
|
|
53
60
|
if (activeSessionId === id) {
|
|
54
|
-
set({ sessions, currentAgentId: null })
|
|
61
|
+
set({ sessions, currentAgentId: null, activeSessionIdOverride: null })
|
|
55
62
|
} else {
|
|
56
|
-
|
|
63
|
+
const overrideId = get().activeSessionIdOverride
|
|
64
|
+
set({ sessions, activeSessionIdOverride: overrideId === id ? null : overrideId })
|
|
57
65
|
}
|
|
58
66
|
},
|
|
59
67
|
clearSessions: async (ids) => {
|
|
@@ -64,9 +72,13 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|
|
64
72
|
invalidateFingerprint('sessions')
|
|
65
73
|
const activeSessionId = selectActiveSessionId(get())
|
|
66
74
|
if (activeSessionId && ids.includes(activeSessionId)) {
|
|
67
|
-
set({ sessions, currentAgentId: null })
|
|
75
|
+
set({ sessions, currentAgentId: null, activeSessionIdOverride: null })
|
|
68
76
|
} else {
|
|
69
|
-
|
|
77
|
+
const overrideId = get().activeSessionIdOverride
|
|
78
|
+
set({
|
|
79
|
+
sessions,
|
|
80
|
+
activeSessionIdOverride: overrideId && ids.includes(overrideId) ? null : overrideId,
|
|
81
|
+
})
|
|
70
82
|
}
|
|
71
83
|
},
|
|
72
84
|
togglePinSession: async (id) => {
|