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
|
// Check if recipient is stale (no heartbeat in 5 min) — route to dead letter queue
|
||||||
let now = std::time::SystemTime::now()
|
// Skip heartbeat check for admin (tier 0) — admin messages always go through
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
if sender_tier > 0 {
|
||||||
.unwrap_or_default()
|
let now = std::time::SystemTime::now()
|
||||||
.as_secs() as i64;
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
let cutoff = now - STALE_HEARTBEAT_SECS;
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let cutoff = now - STALE_HEARTBEAT_SECS;
|
||||||
|
|
||||||
let recipient_stale: bool = db
|
let recipient_stale: bool = db
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COALESCE(h.timestamp, 0) < ?1 FROM agents a \
|
"SELECT COALESCE(h.timestamp, 0) < ?1 FROM agents a \
|
||||||
LEFT JOIN heartbeats h ON a.id = h.agent_id \
|
LEFT JOIN heartbeats h ON a.id = h.agent_id \
|
||||||
WHERE a.id = ?2",
|
WHERE a.id = ?2",
|
||||||
params![cutoff, to_agent],
|
params![cutoff, to_agent],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if recipient_stale {
|
if recipient_stale {
|
||||||
// Queue to dead letter instead of delivering
|
// Queue to dead letter instead of delivering
|
||||||
let error_msg = format!("Recipient '{}' is stale (no heartbeat in {} seconds)", to_agent, STALE_HEARTBEAT_SECS);
|
let error_msg = format!("Recipient '{}' is stale (no heartbeat in {} seconds)", to_agent, STALE_HEARTBEAT_SECS);
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO dead_letter_queue (from_agent, to_agent, content, error) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO dead_letter_queue (from_agent, to_agent, content, error) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![from_agent, to_agent, content, error_msg],
|
params![from_agent, to_agent, content, error_msg],
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Dead letter insert error: {e}"))?;
|
.map_err(|e| format!("Dead letter insert error: {e}"))?;
|
||||||
|
|
||||||
// Also log audit event
|
// Also log audit event
|
||||||
let _ = db.execute(
|
let _ = db.execute(
|
||||||
"INSERT INTO audit_log (agent_id, event_type, detail) VALUES (?1, 'dead_letter', ?2)",
|
"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)],
|
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();
|
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,
|
// 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.
|
// 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(() => {
|
$effect(() => {
|
||||||
if (!autoPrompt || isRunning) return;
|
if (!autoPrompt || isRunning) return;
|
||||||
// Only trigger if session exists and is idle (done/error)
|
// Allow: no session yet (first start) or session completed (done/error)
|
||||||
if (!session || (session.status !== 'done' && session.status !== 'error')) return;
|
if (session && session.status !== 'done' && session.status !== 'error') return;
|
||||||
const prompt = autoPrompt;
|
const prompt = autoPrompt;
|
||||||
|
const resume = !!session; // new query if first start, resume if existing session
|
||||||
onautopromptconsumed?.();
|
onautopromptconsumed?.();
|
||||||
startQuery(prompt, true); // resume session with context refresh
|
startQuery(prompt, resume);
|
||||||
});
|
});
|
||||||
|
|
||||||
let promptRef = $state<HTMLTextAreaElement | undefined>();
|
let promptRef = $state<HTMLTextAreaElement | undefined>();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
type AgentMessageRecord,
|
type AgentMessageRecord,
|
||||||
} from '../../adapters/groups-bridge';
|
} from '../../adapters/groups-bridge';
|
||||||
import { registerSessionProject } from '../../agent-dispatcher';
|
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 { trackProject, updateProjectSession } from '../../stores/health.svelte';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
|
|
@ -97,11 +99,30 @@
|
||||||
lastPromptTime = Date.now();
|
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
|
// Start timer and clean up
|
||||||
startReinjectionTimer();
|
startReinjectionTimer();
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (reinjectionTimer) clearInterval(reinjectionTimer);
|
if (reinjectionTimer) clearInterval(reinjectionTimer);
|
||||||
if (wakeCheckTimer) clearInterval(wakeCheckTimer);
|
if (wakeCheckTimer) clearInterval(wakeCheckTimer);
|
||||||
|
unsubAgentStart();
|
||||||
|
unsubAgentStop();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wake scheduler integration — poll for wake events (Manager agents only)
|
// Wake scheduler integration — poll for wake events (Manager agents only)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { getActiveGroup } from '../../stores/workspace.svelte';
|
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
|
||||||
import {
|
import {
|
||||||
type BtmsgAgent,
|
type BtmsgAgent,
|
||||||
type BtmsgMessage,
|
type BtmsgMessage,
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
getChannelMessages,
|
getChannelMessages,
|
||||||
sendChannelMessage,
|
sendChannelMessage,
|
||||||
createChannel,
|
createChannel,
|
||||||
|
setAgentStatus,
|
||||||
} from '../../adapters/btmsg-bridge';
|
} from '../../adapters/btmsg-bridge';
|
||||||
|
|
||||||
const ADMIN_ID = 'admin';
|
const ADMIN_ID = 'admin';
|
||||||
|
|
@ -127,6 +128,13 @@
|
||||||
try {
|
try {
|
||||||
if (currentView.type === 'dm') {
|
if (currentView.type === 'dm') {
|
||||||
await sendMessage(ADMIN_ID, currentView.agentId, text);
|
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') {
|
} else if (currentView.type === 'channel') {
|
||||||
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
await sendChannelMessage(currentView.channelId, ADMIN_ID, text);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -140,6 +148,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh agent list (reused by poll and wake logic) */
|
||||||
|
async function pollBtmsg() {
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
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 type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
|
||||||
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge';
|
||||||
import type { AgentId } from '../../types/ids';
|
import type { AgentId } from '../../types/ids';
|
||||||
|
|
@ -63,6 +63,12 @@
|
||||||
try {
|
try {
|
||||||
await setAgentStatus(agent.id, newStatus);
|
await setAgentStatus(agent.id, newStatus);
|
||||||
await pollBtmsg(); // Refresh immediately
|
await pollBtmsg(); // Refresh immediately
|
||||||
|
if (newStatus === 'active') {
|
||||||
|
setActiveProject(agent.id);
|
||||||
|
emitAgentStart(agent.id);
|
||||||
|
} else {
|
||||||
|
emitAgentStop(agent.id);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to set agent status:', 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 ---
|
// --- Getters ---
|
||||||
|
|
||||||
export function getGroupsConfig(): GroupsFile | null {
|
export function getGroupsConfig(): GroupsFile | null {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue