diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-11 16:13:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-11 16:13:17 +0300 |
| commit | 55cdf2e8f8af2dd3feebb4bb7d44c9cb7e23b384 (patch) | |
| tree | 91feb43a39812d63e19fc5068f95dbe0c896e118 /src/integration | |
| parent | e201a647635905a5141bfce335619b0e232b396d (diff) | |
fix: stabilize integration agent state reports
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/claude/herdr-agent-state.sh | 5 | ||||
| -rw-r--r-- | src/integration/assets/codex/herdr-agent-state.sh | 5 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 13 | ||||
| -rw-r--r-- | src/integration/assets/pi/herdr-agent-state.ts | 172 | ||||
| -rw-r--r-- | src/integration/mod.rs | 239 |
5 files changed, 407 insertions, 27 deletions
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 527fe9f8..18b042e8 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -1,6 +1,8 @@ #!/bin/sh # installed by herdr # safe to edit. this hook only activates inside herdr-managed panes. +# HERDR_INTEGRATION_ID=claude +# HERDR_INTEGRATION_VERSION=1 set -eu @@ -50,6 +52,7 @@ if is_subagent and action in ("idle", "release"): action = "working" request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() if action == "release": request = { "id": request_id, @@ -58,6 +61,7 @@ if action == "release": "pane_id": pane_id, "source": source, "agent": "claude", + "seq": report_seq, }, } else: @@ -69,6 +73,7 @@ else: "source": source, "agent": "claude", "state": action, + "seq": report_seq, }, } diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index 01510d6d..c6ee287c 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -1,6 +1,8 @@ #!/bin/sh # installed by herdr # safe to edit. this hook only activates inside herdr-managed panes. +# HERDR_INTEGRATION_ID=codex +# HERDR_INTEGRATION_VERSION=1 set -eu @@ -33,6 +35,7 @@ if not pane_id or not socket_path: raise SystemExit(0) request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() if action == "release": request = { "id": request_id, @@ -41,6 +44,7 @@ if action == "release": "pane_id": pane_id, "source": source, "agent": "codex", + "seq": report_seq, }, } else: @@ -52,6 +56,7 @@ else: "source": source, "agent": "codex", "state": action, + "seq": report_seq, }, } diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 6d5e3c87..0fb96217 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -1,6 +1,17 @@ +// installed by herdr +// safe to edit. this plugin only activates inside herdr-managed panes. +// HERDR_INTEGRATION_ID=opencode +// HERDR_INTEGRATION_VERSION=1 + import net from "node:net"; const SOURCE = "herdr:opencode"; +let reportSeq = Date.now() * 1000; + +function nextReportSeq() { + reportSeq += 1; + return reportSeq; +} function reportState(action) { const paneId = process.env.HERDR_PANE_ID; @@ -22,12 +33,14 @@ function reportState(action) { pane_id: paneId, source: SOURCE, agent: "opencode", + seq: nextReportSeq(), } : { pane_id: paneId, source: SOURCE, agent: "opencode", state: action, + seq: nextReportSeq(), }, }; diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index f51f05d1..26869f1c 100644 --- a/src/integration/assets/pi/herdr-agent-state.ts +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -1,5 +1,7 @@ // installed by herdr // safe to edit. this integration only activates inside herdr-managed panes. +// HERDR_INTEGRATION_ID=pi +// HERDR_INTEGRATION_VERSION=1 // @ts-nocheck import { createConnection } from "node:net"; @@ -37,7 +39,38 @@ function sendRequest(request: unknown): Promise<void> { }); } -function sendState(state: "working" | "blocked" | "idle", message?: string): Promise<void> { +type AgentState = "working" | "blocked" | "idle"; + +type QueuedState = { + state: AgentState; + message?: string; + seq: number; +}; + +const idleDebounceMs = parseDurationEnv("HERDR_PI_IDLE_DEBOUNCE_MS", 250); +const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500); +const retryableErrorPattern = + /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i; +let reportSeq = Date.now() * 1000; + +function nextReportSeq(): number { + reportSeq += 1; + return reportSeq; +} + +function parseDurationEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> { return sendRequest({ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", @@ -47,10 +80,65 @@ function sendState(state: "working" | "blocked" | "idle", message?: string): Pro agent: "pi", state, message, + seq, }, }); } +let sendInFlight = false; +let queuedState: QueuedState | undefined; + +function queueState(state: AgentState, message?: string): void { + queuedState = { state, message, seq: nextReportSeq() }; + if (!sendInFlight) { + void drainStateQueue(); + } +} + +async function drainStateQueue(): Promise<void> { + if (sendInFlight) { + return; + } + + sendInFlight = true; + try { + while (queuedState) { + const next = queuedState; + queuedState = undefined; + await sendState(next.state, next.message, next.seq); + } + } finally { + sendInFlight = false; + if (queuedState) { + void drainStateQueue(); + } + } +} + +function lastAssistantMessage(messages: unknown[]): any | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] as any; + if (message?.role === "assistant") { + return message; + } + } + return undefined; +} + +function retryableErrorMessage(event: any): string | undefined { + const messages = Array.isArray(event?.messages) ? event.messages : []; + const assistant = lastAssistantMessage(messages); + if (assistant?.stopReason !== "error") { + return undefined; + } + + const errorMessage = String(assistant.errorMessage ?? ""); + if (!retryableErrorPattern.test(errorMessage)) { + return undefined; + } + return errorMessage || "retryable provider error"; +} + function releaseAgent(): Promise<void> { return sendRequest({ id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, @@ -59,6 +147,7 @@ function releaseAgent(): Promise<void> { pane_id: paneId, source, agent: "pi", + seq: nextReportSeq(), }, }); } @@ -69,16 +158,43 @@ export default function (pi) { } let agentActive = false; + let retryHoldActive = false; + let failureBlocked = false; + let failureMessage: string | undefined; let blockedCount = 0; let blockedMessage: string | undefined; - let lastState: "working" | "blocked" | "idle" | undefined; + let lastState: AgentState | undefined; let lastMessage: string | undefined; + let idleTimer: ReturnType<typeof setTimeout> | undefined; + let retryTimer: ReturnType<typeof setTimeout> | undefined; + + function clearTimer(timer: ReturnType<typeof setTimeout> | undefined) { + if (timer) { + clearTimeout(timer); + } + } + + function clearPendingTimers() { + clearTimer(idleTimer); + clearTimer(retryTimer); + idleTimer = undefined; + retryTimer = undefined; + } + + function clearFailureState() { + retryHoldActive = false; + failureBlocked = false; + failureMessage = undefined; + } function desiredState() { if (blockedCount > 0) { return { state: "blocked" as const, message: blockedMessage }; } - if (agentActive) { + if (failureBlocked) { + return { state: "blocked" as const, message: failureMessage }; + } + if (agentActive || retryHoldActive) { return { state: "working" as const, message: undefined }; } return { state: "idle" as const, message: undefined }; @@ -91,7 +207,33 @@ export default function (pi) { } lastState = next.state; lastMessage = next.message; - void sendState(next.state, next.message); + queueState(next.state, next.message); + } + + function scheduleIdle() { + clearPendingTimers(); + clearFailureState(); + idleTimer = setTimeout(() => { + idleTimer = undefined; + publishState(); + }, idleDebounceMs); + idleTimer.unref?.(); + } + + function holdForRetry(message: string) { + clearPendingTimers(); + retryHoldActive = true; + failureBlocked = false; + failureMessage = message; + publishState(); + + retryTimer = setTimeout(() => { + retryTimer = undefined; + retryHoldActive = false; + failureBlocked = true; + publishState(); + }, retryGraceMs); + retryTimer.unref?.(); } pi.events.on("herdr:blocked", (data) => { @@ -104,22 +246,40 @@ export default function (pi) { return; } + clearPendingTimers(); blockedCount += 1; blockedMessage = data.label; publishState(); }); pi.on("agent_start", () => { + clearPendingTimers(); + clearFailureState(); agentActive = true; publishState(); }); - pi.on("agent_end", () => { + pi.on("agent_end", (event) => { + if (!agentActive) { + // Pi can emit duplicate/late end events while auto-retry is already + // holding the pane in Working. Do not let an unqualified duplicate end + // cancel the retry hold and publish a false Idle. + return; + } + agentActive = false; - publishState(); + + const retryableMessage = retryableErrorMessage(event); + if (retryableMessage) { + holdForRetry(retryableMessage); + return; + } + + scheduleIdle(); }); pi.on("session_shutdown", async () => { + clearPendingTimers(); await releaseAgent(); }); } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 402e29a7..1e5c2d68 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -1,6 +1,8 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; use portable_pty::CommandBuilder; use serde_json::{json, Map, Value}; @@ -10,12 +12,17 @@ use crate::layout::PaneId; pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID"; const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); +const PI_INTEGRATION_VERSION: u32 = 1; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); +const CLAUDE_INTEGRATION_VERSION: u32 = 1; const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); +const CODEX_INTEGRATION_VERSION: u32 = 1; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); +const OPENCODE_INTEGRATION_VERSION: u32 = 1; +const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; #[derive(Debug)] pub(crate) struct ClaudeInstallPaths { @@ -35,6 +42,22 @@ pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IntegrationStatus { + pub target: crate::api::schema::IntegrationTarget, + pub path: PathBuf, + pub state: IntegrationStatusKind, + pub installed_version: Option<u32>, + pub expected_version: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IntegrationStatusKind { + NotInstalled, + Current, + Outdated, +} + #[derive(Debug)] pub(crate) struct PiUninstallResult { pub extension_path: PathBuf, @@ -213,7 +236,9 @@ pub(crate) fn uninstall_target( Ok(messages) } -fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &'static str { +pub(crate) fn integration_target_label( + target: crate::api::schema::IntegrationTarget, +) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", crate::api::schema::IntegrationTarget::Claude => "claude", @@ -222,6 +247,136 @@ fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &' } } +pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + Some(integration_status_at(target, path.ok()?, expected_version)) + }) + .collect() +} + +fn outdated_installed_integrations() -> Vec<IntegrationStatus> { + installed_integration_statuses() + .into_iter() + .filter(|status| status.state == IntegrationStatusKind::Outdated) + .collect() +} + +fn integration_specs() -> [( + crate::api::schema::IntegrationTarget, + io::Result<PathBuf>, + u32, +); 4] { + [ + ( + crate::api::schema::IntegrationTarget::Pi, + pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)), + PI_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Claude, + claude_dir().map(|dir| dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)), + CLAUDE_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Codex, + codex_dir().map(|dir| dir.join(CODEX_HOOK_INSTALL_NAME)), + CODEX_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Opencode, + opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)), + OPENCODE_INTEGRATION_VERSION, + ), + ] +} + +pub(crate) fn integration_update_instructions( + targets: &[crate::api::schema::IntegrationTarget], +) -> String { + let commands: Vec<String> = targets + .iter() + .map(|target| { + format!( + "`herdr integration install {}`", + integration_target_label(*target) + ) + }) + .collect(); + + match commands.as_slice() { + [] => String::new(), + [command] => format!("run {command}"), + [rest @ .., last] => format!("run {} and {last}", rest.join(", ")), + } +} + +pub(crate) fn print_outdated_update_notice() -> bool { + let outdated = outdated_installed_integrations(); + if outdated.is_empty() { + return false; + } + + let targets = outdated + .iter() + .map(|integration| integration.target) + .collect::<Vec<_>>(); + eprintln!( + "installed herdr integrations need updating; {}.", + integration_update_instructions(&targets).replace('`', "") + ); + true +} + +fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> IntegrationStatus { + if !path.is_file() { + return IntegrationStatus { + target, + path, + state: IntegrationStatusKind::NotInstalled, + installed_version: None, + expected_version, + }; + } + + let installed_version = fs::read_to_string(&path) + .ok() + .and_then(|content| parse_integration_version(&content)); + let state = if installed_version.is_some_and(|version| version >= expected_version) { + IntegrationStatusKind::Current + } else { + IntegrationStatusKind::Outdated + }; + + IntegrationStatus { + target, + path, + state, + installed_version, + expected_version, + } +} + +fn parse_integration_version(content: &str) -> Option<u32> { + content.lines().find_map(|line| { + let marker_line = line + .trim() + .trim_start_matches('/') + .trim_start_matches('#') + .trim(); + marker_line + .strip_prefix(INTEGRATION_VERSION_MARKER)? + .trim() + .parse() + .ok() + }) +} + pub(crate) fn install_pi() -> io::Result<PathBuf> { let dir = pi_extension_dir()?; if !dir.is_dir() { @@ -816,9 +971,14 @@ fn home_dir() -> io::Result<PathBuf> { } #[cfg(test)] +pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +} + +#[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, MutexGuard, OnceLock}; fn unique_base() -> PathBuf { std::env::temp_dir().join(format!( @@ -831,14 +991,9 @@ mod tests { )) } - fn env_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() - } - #[test] fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let ext_dir = home.join(".pi/agent/extensions"); @@ -857,7 +1012,7 @@ mod tests { #[test] fn uninstall_pi_removes_embedded_extension_when_present() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let ext_dir = home.join(".pi/agent/extensions"); @@ -879,8 +1034,50 @@ mod tests { } #[test] + fn outdated_integrations_treat_missing_version_marker_as_legacy() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&extension_path, "// installed by herdr\n").unwrap(); + std::env::set_var("HOME", &home); + + let outdated = outdated_installed_integrations(); + + assert_eq!(outdated.len(), 1); + assert_eq!( + outdated[0].target, + crate::api::schema::IntegrationTarget::Pi + ); + assert_eq!(outdated[0].path, extension_path); + assert_eq!(outdated[0].installed_version, None); + assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn outdated_integrations_accept_current_version_marker() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap(); + std::env::set_var("HOME", &home); + + assert!(outdated_installed_integrations().is_empty()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_pi_errors_when_extension_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -896,7 +1093,7 @@ mod tests { #[test] fn install_claude_writes_hook_and_updates_settings() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -965,7 +1162,7 @@ mod tests { #[test] fn install_claude_is_idempotent_for_hook_entries() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -1017,7 +1214,7 @@ mod tests { #[test] fn uninstall_claude_removes_herdr_hooks_and_preserves_others() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -1073,7 +1270,7 @@ mod tests { #[test] fn install_claude_errors_when_claude_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -1089,7 +1286,7 @@ mod tests { #[test] fn install_codex_writes_hook_and_updates_hooks_and_config() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1133,7 +1330,7 @@ mod tests { #[test] fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1169,7 +1366,7 @@ mod tests { #[test] fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1224,7 +1421,7 @@ mod tests { #[test] fn install_codex_errors_when_config_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -1240,7 +1437,7 @@ mod tests { #[test] fn install_opencode_writes_plugin_to_plugins_dir() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let opencode_dir = home.join(".config/opencode"); @@ -1264,7 +1461,7 @@ mod tests { #[test] fn uninstall_opencode_removes_plugin_when_present() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let opencode_dir = home.join(".config/opencode/plugins"); @@ -1287,7 +1484,7 @@ mod tests { #[test] fn install_opencode_errors_when_config_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); |
