Wire play/stop buttons and DM send to agent lifecycle
- Play button in GroupAgentsPanel now starts agent session via emitAgentStart - Stop button now stops running agent session via emitAgentStop - Sending a DM to a stopped agent auto-wakes it (sets active + emitAgentStart) - Fix autoPrompt in AgentPane to work for fresh sessions (not just done/error) - Fix btmsg: admin (tier 0) bypasses stale heartbeat check so messages deliver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
42907f22a4
commit
2c710fa0db
6 changed files with 114 additions and 34 deletions
|
|
@ -215,38 +215,41 @@ pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<S
|
|||
}
|
||||
|
||||
// Check if recipient is stale (no heartbeat in 5 min) — route to dead letter queue
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let cutoff = now - STALE_HEARTBEAT_SECS;
|
||||
// Skip heartbeat check for admin (tier 0) — admin messages always go through
|
||||
if sender_tier > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let cutoff = now - STALE_HEARTBEAT_SECS;
|
||||
|
||||
let recipient_stale: bool = db
|
||||
.query_row(
|
||||
"SELECT COALESCE(h.timestamp, 0) < ?1 FROM agents a \
|
||||
LEFT JOIN heartbeats h ON a.id = h.agent_id \
|
||||
WHERE a.id = ?2",
|
||||
params![cutoff, to_agent],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
let recipient_stale: bool = db
|
||||
.query_row(
|
||||
"SELECT COALESCE(h.timestamp, 0) < ?1 FROM agents a \
|
||||
LEFT JOIN heartbeats h ON a.id = h.agent_id \
|
||||
WHERE a.id = ?2",
|
||||
params![cutoff, to_agent],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if recipient_stale {
|
||||
// Queue to dead letter instead of delivering
|
||||
let error_msg = format!("Recipient '{}' is stale (no heartbeat in {} seconds)", to_agent, STALE_HEARTBEAT_SECS);
|
||||
db.execute(
|
||||
"INSERT INTO dead_letter_queue (from_agent, to_agent, content, error) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![from_agent, to_agent, content, error_msg],
|
||||
)
|
||||
.map_err(|e| format!("Dead letter insert error: {e}"))?;
|
||||
if recipient_stale {
|
||||
// Queue to dead letter instead of delivering
|
||||
let error_msg = format!("Recipient '{}' is stale (no heartbeat in {} seconds)", to_agent, STALE_HEARTBEAT_SECS);
|
||||
db.execute(
|
||||
"INSERT INTO dead_letter_queue (from_agent, to_agent, content, error) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![from_agent, to_agent, content, error_msg],
|
||||
)
|
||||
.map_err(|e| format!("Dead letter insert error: {e}"))?;
|
||||
|
||||
// Also log audit event
|
||||
let _ = db.execute(
|
||||
"INSERT INTO audit_log (agent_id, event_type, detail) VALUES (?1, 'dead_letter', ?2)",
|
||||
params![from_agent, format!("Message to '{}' routed to dead letter queue: {}", to_agent, error_msg)],
|
||||
);
|
||||
// Also log audit event
|
||||
let _ = db.execute(
|
||||
"INSERT INTO audit_log (agent_id, event_type, detail) VALUES (?1, 'dead_letter', ?2)",
|
||||
params![from_agent, format!("Message to '{}' routed to dead letter queue: {}", to_agent, error_msg)],
|
||||
);
|
||||
|
||||
return Err(error_msg);
|
||||
return Err(error_msg);
|
||||
}
|
||||
}
|
||||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
|
|
|
|||
|
|
@ -151,14 +151,15 @@
|
|||
// NOTE: Do NOT stop agents in onDestroy — it fires on layout changes/remounts,
|
||||
// not just explicit close. Stop-on-close is handled by workspace teardown.
|
||||
|
||||
// Auto-prompt: pick up externally triggered prompts (e.g. periodic context refresh)
|
||||
// Auto-prompt: pick up externally triggered prompts (e.g. periodic context refresh, play button)
|
||||
$effect(() => {
|
||||
if (!autoPrompt || isRunning) return;
|
||||
// Only trigger if session exists and is idle (done/error)
|
||||
if (!session || (session.status !== 'done' && session.status !== 'error')) return;
|
||||
// Allow: no session yet (first start) or session completed (done/error)
|
||||
if (session && session.status !== 'done' && session.status !== 'error') return;
|
||||
const prompt = autoPrompt;
|
||||
const resume = !!session; // new query if first start, resume if existing session
|
||||
onautopromptconsumed?.();
|
||||
startQuery(prompt, true); // resume session with context refresh
|
||||
startQuery(prompt, resume);
|
||||
});
|
||||
|
||||
let promptRef = $state<HTMLTextAreaElement | undefined>();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
type AgentMessageRecord,
|
||||
} from '../../adapters/groups-bridge';
|
||||
import { registerSessionProject } from '../../agent-dispatcher';
|
||||
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
|
||||
import { stopAgent } from '../../adapters/agent-bridge';
|
||||
import { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
||||
import {
|
||||
createAgentSession,
|
||||
|
|
@ -97,11 +99,30 @@
|
|||
lastPromptTime = Date.now();
|
||||
}
|
||||
|
||||
// Listen for play-button start events from GroupAgentsPanel
|
||||
const unsubAgentStart = onAgentStart((projectId) => {
|
||||
if (projectId !== project.id) return;
|
||||
// Only auto-start if not already running and no pending prompt
|
||||
if (contextRefreshPrompt) return;
|
||||
const startPrompt = project.isAgent
|
||||
? 'Start your work. Check your inbox with `btmsg inbox` and review the task board with `bttask board`. Take action on any pending items.'
|
||||
: 'Review the instructions above and begin your work.';
|
||||
contextRefreshPrompt = startPrompt;
|
||||
});
|
||||
|
||||
// Listen for stop-button events from GroupAgentsPanel
|
||||
const unsubAgentStop = onAgentStop((projectId) => {
|
||||
if (projectId !== project.id) return;
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
});
|
||||
|
||||
// Start timer and clean up
|
||||
startReinjectionTimer();
|
||||
onDestroy(() => {
|
||||
if (reinjectionTimer) clearInterval(reinjectionTimer);
|
||||
if (wakeCheckTimer) clearInterval(wakeCheckTimer);
|
||||
unsubAgentStart();
|
||||
unsubAgentStop();
|
||||
});
|
||||
|
||||
// Wake scheduler integration — poll for wake events (Manager agents only)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
||||
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
|
||||
import {
|
||||
type BtmsgAgent,
|
||||
type BtmsgMessage,
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
getChannelMessages,
|
||||
sendChannelMessage,
|
||||
createChannel,
|
||||
setAgentStatus,
|
||||
} from '../../adapters/btmsg-bridge';
|
||||
|
||||
const ADMIN_ID = 'admin';
|
||||
|
|
@ -127,6 +128,13 @@
|
|||
try {
|
||||
if (currentView.type === 'dm') {
|
||||
await sendMessage(ADMIN_ID, currentView.agentId, text);
|
||||
// Auto-wake agent if stopped
|
||||
const recipient = agents.find(a => a.id === currentView.agentId);
|
||||
if (recipient && recipient.status !== 'active') {
|
||||
await setAgentStatus(currentView.agentId, 'active');
|
||||
emitAgentStart(currentView.agentId);
|
||||
await pollBtmsg();
|
||||
}
|
||||
} else if (currentView.type === 'channel') {
|
||||
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
||||
} else {
|
||||
|
|
@ -140,6 +148,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Refresh agent list (reused by poll and wake logic) */
|
||||
async function pollBtmsg() {
|
||||
await loadData();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
|
||||
import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
|
||||
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
|
||||
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
||||
import type { AgentId } from '../../types/ids';
|
||||
|
|
@ -63,6 +63,12 @@
|
|||
try {
|
||||
await setAgentStatus(agent.id, newStatus);
|
||||
await pollBtmsg(); // Refresh immediately
|
||||
if (newStatus === 'active') {
|
||||
setActiveProject(agent.id);
|
||||
emitAgentStart(agent.id);
|
||||
} else {
|
||||
emitAgentStop(agent.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to set agent status:', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,42 @@ export function emitTerminalToggle(projectId: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Agent start event (play button in GroupAgentsPanel) ---
|
||||
|
||||
type AgentStartCallback = (projectId: string) => void;
|
||||
let agentStartCallbacks: AgentStartCallback[] = [];
|
||||
|
||||
export function onAgentStart(cb: AgentStartCallback): () => void {
|
||||
agentStartCallbacks.push(cb);
|
||||
return () => {
|
||||
agentStartCallbacks = agentStartCallbacks.filter(c => c !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitAgentStart(projectId: string): void {
|
||||
for (const cb of agentStartCallbacks) {
|
||||
cb(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Agent stop event (stop button in GroupAgentsPanel) ---
|
||||
|
||||
type AgentStopCallback = (projectId: string) => void;
|
||||
let agentStopCallbacks: AgentStopCallback[] = [];
|
||||
|
||||
export function onAgentStop(cb: AgentStopCallback): () => void {
|
||||
agentStopCallbacks.push(cb);
|
||||
return () => {
|
||||
agentStopCallbacks = agentStopCallbacks.filter(c => c !== cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function emitAgentStop(projectId: string): void {
|
||||
for (const cb of agentStopCallbacks) {
|
||||
cb(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Getters ---
|
||||
|
||||
export function getGroupsConfig(): GroupsFile | null {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue