mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
108 lines
4.8 KiB
Java
108 lines
4.8 KiB
Java
package vip.mate.agent.delegation;
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|
import org.springframework.stereotype.Component;
|
|
import vip.mate.channel.web.ChatStreamTracker;
|
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* Periodic watchdog that flips a sub-agent's status to {@code stale} when its
|
|
* child stream stops making observable progress.
|
|
*
|
|
* <p>Progress is probed via {@link ChatStreamTracker#getRunningToolName} and
|
|
* {@link ChatStreamTracker#getCurrentPhase}. When neither has changed across
|
|
* the configured number of cycles, the record is marked stale and a
|
|
* {@code subagent_stale} event is broadcast on the parent conversation so the
|
|
* UI can surface the issue. Cycle count uses two separate thresholds — one
|
|
* for idle children and one for children mid-tool — because legitimately slow
|
|
* tools (large file scans, slow LLM calls) need a longer window than an idle
|
|
* model that has simply gone quiet.
|
|
*
|
|
* <p>The runtime tool name + phase combination is a deliberately coarse
|
|
* progress signal: it does not require introspecting LLM token deltas, which
|
|
* keeps the watchdog cheap and avoids racing with the streaming hot path.
|
|
*/
|
|
@Slf4j
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class SubagentHeartbeat {
|
|
|
|
private final SubagentRegistry registry;
|
|
private final SubagentHeartbeatConfig cfg;
|
|
private final ChatStreamTracker streamTracker;
|
|
|
|
/**
|
|
* Scheduled tick. Defaults to every 30 s; controlled by
|
|
* {@code mateclaw.delegation.heartbeat.intervalSec}.
|
|
*/
|
|
@Scheduled(fixedRateString = "#{@subagentHeartbeatConfig.intervalSec * 1000L}")
|
|
public void check() {
|
|
for (var rec : registry.allActive()) {
|
|
if (!"running".equals(rec.status().get())) {
|
|
continue;
|
|
}
|
|
evaluate(rec);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Visible for testing — apply one heartbeat tick to a single record so
|
|
* tests can drive the watchdog deterministically without scheduling.
|
|
*/
|
|
void evaluate(SubagentRegistry.SubagentRecord rec) {
|
|
// Probe child progress. We use (currentTool, currentPhase) as the
|
|
// monotonic-progress signal: any change in either implies the child
|
|
// advanced at least one observable step. We deliberately do NOT
|
|
// depend on a private apiCallCount field — the RunState does not
|
|
// expose one, and counting deltas across the streaming hot path
|
|
// would race with token emission. Tool/phase ticks are atomic
|
|
// volatile writes from the streaming layer, so reading them here
|
|
// is cheap and correct.
|
|
String currentTool = streamTracker.getRunningToolName(rec.childConversationId());
|
|
String currentPhase = streamTracker.getCurrentPhase(rec.childConversationId());
|
|
int phaseHash = currentPhase != null ? currentPhase.hashCode() : 0;
|
|
|
|
boolean toolChanged = !Objects.equals(currentTool, rec.lastSeenTool().get());
|
|
boolean phaseChanged = phaseHash != rec.lastSeenIter().get();
|
|
|
|
if (toolChanged || phaseChanged) {
|
|
rec.lastSeenTool().set(currentTool);
|
|
rec.lastSeenIter().set(phaseHash);
|
|
rec.staleCount().set(0);
|
|
return;
|
|
}
|
|
|
|
int sc = rec.staleCount().incrementAndGet();
|
|
int limit = (currentTool != null && !currentTool.isEmpty())
|
|
? cfg.getStaleCyclesInTool()
|
|
: cfg.getStaleCyclesIdle();
|
|
|
|
if (sc >= limit) {
|
|
// Atomic transition: only the first thread to flip running -> stale
|
|
// emits the event. Subsequent ticks fall through the running guard
|
|
// in check().
|
|
if (rec.status().compareAndSet("running", "stale")) {
|
|
Map<String, Object> payload = new LinkedHashMap<>();
|
|
payload.put("subagentId", rec.subagentId());
|
|
payload.put("parentSubagentId", rec.parentSubagentId());
|
|
payload.put("depth", rec.depth());
|
|
payload.put("cycles", sc);
|
|
payload.put("lastTool", currentTool != null ? currentTool : "");
|
|
payload.put("elapsedMs", System.currentTimeMillis() - rec.startedAt());
|
|
// Broadcast to the root (human-facing) conversation so the event
|
|
// reaches the stream the user is watching at any tree depth.
|
|
String target = rec.rootConversationId() != null
|
|
? rec.rootConversationId() : rec.parentConversationId();
|
|
streamTracker.broadcastObject(target, "subagent_stale", payload);
|
|
log.info("[SubagentHeartbeat] subagent {} marked stale after {} idle cycles (limit={})",
|
|
rec.subagentId(), sc, limit);
|
|
}
|
|
}
|
|
}
|
|
}
|