feat: improvements for scheduler

This commit is contained in:
Richard Tang
2026-04-20 10:49:37 -07:00
parent 923e773c14
commit 3c91119f67
8 changed files with 523 additions and 14 deletions
+87 -3
View File
@@ -10,6 +10,7 @@ Session-primary routes:
- GET /api/sessions/{session_id}/stats runtime statistics - GET /api/sessions/{session_id}/stats runtime statistics
- GET /api/sessions/{session_id}/entry-points list entry points - GET /api/sessions/{session_id}/entry-points list entry points
- PATCH /api/sessions/{session_id}/triggers/{id} update trigger task - PATCH /api/sessions/{session_id}/triggers/{id} update trigger task
- POST /api/sessions/{session_id}/triggers/{id}/run fire trigger once (manual)
- GET /api/sessions/{session_id}/colonies list colony IDs - GET /api/sessions/{session_id}/colonies list colony IDs
- GET /api/sessions/{session_id}/events/history persisted eventbus log (for replay) - GET /api/sessions/{session_id}/events/history persisted eventbus log (for replay)
@@ -247,7 +248,14 @@ async def handle_get_live_session(request: web.Request) -> web.Response:
} }
mono = getattr(session, "trigger_next_fire", {}).get(t.id) mono = getattr(session, "trigger_next_fire", {}).get(t.id)
if mono is not None: if mono is not None:
entry["next_fire_in"] = max(0.0, mono - time.monotonic()) remaining = max(0.0, mono - time.monotonic())
entry["next_fire_in"] = remaining
entry["next_fire_at"] = int((time.time() + remaining) * 1000)
stats = getattr(session, "trigger_fire_stats", {}).get(t.id)
if stats:
entry["fire_count"] = stats.get("fire_count", 0)
if stats.get("last_fired_at") is not None:
entry["last_fired_at"] = stats["last_fired_at"]
data["entry_points"].append(entry) data["entry_points"].append(entry)
data["colonies"] = session.colony_runtime.list_graphs() data["colonies"] = session.colony_runtime.list_graphs()
@@ -397,7 +405,14 @@ async def handle_session_entry_points(request: web.Request) -> web.Response:
} }
mono = getattr(session, "trigger_next_fire", {}).get(t.id) mono = getattr(session, "trigger_next_fire", {}).get(t.id)
if mono is not None: if mono is not None:
entry["next_fire_in"] = max(0.0, mono - time.monotonic()) remaining = max(0.0, mono - time.monotonic())
entry["next_fire_in"] = remaining
entry["next_fire_at"] = int((time.time() + remaining) * 1000)
stats = getattr(session, "trigger_fire_stats", {}).get(t.id)
if stats:
entry["fire_count"] = stats.get("fire_count", 0)
if stats.get("last_fired_at") is not None:
entry["last_fired_at"] = stats["last_fired_at"]
entry_points.append(entry) entry_points.append(entry)
return web.json_response({"entry_points": entry_points}) return web.json_response({"entry_points": entry_points})
@@ -548,6 +563,60 @@ async def handle_update_trigger_task(request: web.Request) -> web.Response:
) )
async def handle_run_trigger(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/triggers/{trigger_id}/run — fire the trigger once.
Manual invocation for testing. Works whether the trigger is active or
inactive; does not change active state and does not reset the scheduled
next-fire time of an active timer.
"""
session, err = resolve_session(request)
if err:
return err
trigger_id = request.match_info["trigger_id"]
tdef = getattr(session, "available_triggers", {}).get(trigger_id)
if tdef is None:
return web.json_response(
{"error": f"Trigger '{trigger_id}' not found"},
status=404,
)
if getattr(session, "colony_runtime", None) is None:
return web.json_response({"error": "Colony not loaded"}, status=409)
executor = getattr(session, "queen_executor", None)
queen_node = getattr(executor, "node_registry", {}).get("queen") if executor else None
if queen_node is None:
return web.json_response({"error": "Queen not ready"}, status=409)
from framework.agent_loop.agent_loop import TriggerEvent
try:
await queen_node.inject_trigger(
TriggerEvent(
trigger_type=tdef.trigger_type,
source_id=trigger_id,
payload={
"task": tdef.task or "",
"trigger_config": tdef.trigger_config,
"forced": True,
},
)
)
except Exception as exc: # noqa: BLE001
return web.json_response(
{"error": f"Failed to fire trigger: {exc}"},
status=500,
)
from framework.tools.queen_lifecycle_tools import _emit_trigger_fired
await _emit_trigger_fired(session, trigger_id, tdef.trigger_type)
return web.json_response({"status": "fired", "trigger_id": trigger_id})
async def handle_activate_trigger(request: web.Request) -> web.Response: async def handle_activate_trigger(request: web.Request) -> web.Response:
"""POST /api/sessions/{session_id}/triggers/{trigger_id}/activate — start a trigger.""" """POST /api/sessions/{session_id}/triggers/{trigger_id}/activate — start a trigger."""
session, err = resolve_session(request) session, err = resolve_session(request)
@@ -599,6 +668,17 @@ async def handle_activate_trigger(request: web.Request) -> web.Response:
runner = getattr(session, "runner", None) runner = getattr(session, "runner", None)
colony_entry = runner.graph.entry_node if runner else None colony_entry = runner.graph.entry_node if runner else None
config_out = dict(tdef.trigger_config)
mono = getattr(session, "trigger_next_fire", {}).get(trigger_id)
if mono is not None:
remaining = max(0.0, mono - time.monotonic())
config_out["next_fire_in"] = remaining
config_out["next_fire_at"] = int((time.time() + remaining) * 1000)
stats = getattr(session, "trigger_fire_stats", {}).get(trigger_id)
if stats:
config_out["fire_count"] = stats.get("fire_count", 0)
if stats.get("last_fired_at") is not None:
config_out["last_fired_at"] = stats["last_fired_at"]
await bus.publish( await bus.publish(
AgentEvent( AgentEvent(
type=EventType.TRIGGER_ACTIVATED, type=EventType.TRIGGER_ACTIVATED,
@@ -606,7 +686,7 @@ async def handle_activate_trigger(request: web.Request) -> web.Response:
data={ data={
"trigger_id": trigger_id, "trigger_id": trigger_id,
"trigger_type": tdef.trigger_type, "trigger_type": tdef.trigger_type,
"trigger_config": tdef.trigger_config, "trigger_config": config_out,
"name": tdef.description or trigger_id, "name": tdef.description or trigger_id,
**({"entry_node": colony_entry} if colony_entry else {}), **({"entry_node": colony_entry} if colony_entry else {}),
}, },
@@ -1022,6 +1102,10 @@ def register_routes(app: web.Application) -> None:
"/api/sessions/{session_id}/triggers/{trigger_id}/deactivate", "/api/sessions/{session_id}/triggers/{trigger_id}/deactivate",
handle_deactivate_trigger, handle_deactivate_trigger,
) )
app.router.add_post(
"/api/sessions/{session_id}/triggers/{trigger_id}/run",
handle_run_trigger,
)
app.router.add_get("/api/sessions/{session_id}/colonies", handle_session_colonies) app.router.add_get("/api/sessions/{session_id}/colonies", handle_session_colonies)
app.router.add_get("/api/sessions/{session_id}/events/history", handle_session_events_history) app.router.add_get("/api/sessions/{session_id}/events/history", handle_session_events_history)
+24 -1
View File
@@ -93,6 +93,9 @@ class Session:
worker_configured: bool = False worker_configured: bool = False
# Monotonic timestamps for next trigger fire (mirrors AgentRuntime._timer_next_fire) # Monotonic timestamps for next trigger fire (mirrors AgentRuntime._timer_next_fire)
trigger_next_fire: dict[str, float] = field(default_factory=dict) trigger_next_fire: dict[str, float] = field(default_factory=dict)
# Per-trigger fire stats (session lifetime): {trigger_id: {"fire_count": int, "last_fired_at": epoch_ms}}.
# Reset on process restart — good enough as a "since this session started" counter.
trigger_fire_stats: dict[str, dict[str, Any]] = field(default_factory=dict)
# Session directory resumption: # Session directory resumption:
# When set, _start_queen writes queen conversations to this existing session's # When set, _start_queen writes queen conversations to this existing session's
# directory instead of creating a new one. This lets cold-restores accumulate # directory instead of creating a new one. This lets cold-restores accumulate
@@ -1607,8 +1610,28 @@ class SessionManager:
# Resolve entry node for trigger target # Resolve entry node for trigger target
runner = getattr(session, "runner", None) runner = getattr(session, "runner", None)
colony_entry = runner.graph.entry_node if runner else None colony_entry = runner.graph.entry_node if runner else None
fire_times = getattr(session, "trigger_next_fire", {})
fire_stats = getattr(session, "trigger_fire_stats", {})
now_mono = time.monotonic()
now_wall = time.time()
for t in triggers.values(): for t in triggers.values():
# Merge ephemeral next-fire data + historical fire stats into
# trigger_config so the UI can render a live-ticking countdown
# and a "fired Nx · last 2m ago" badge. `next_fire_at` is epoch
# milliseconds (wall clock) — the frontend anchors its ticker
# on this. `next_fire_in` is kept for legacy consumers.
config_out = dict(t.trigger_config)
mono = fire_times.get(t.id)
if mono is not None:
remaining = max(0.0, mono - now_mono)
config_out["next_fire_in"] = remaining
config_out["next_fire_at"] = int((now_wall + remaining) * 1000)
stats = fire_stats.get(t.id)
if stats:
config_out["fire_count"] = stats.get("fire_count", 0)
if stats.get("last_fired_at") is not None:
config_out["last_fired_at"] = stats["last_fired_at"]
await session.event_bus.publish( await session.event_bus.publish(
AgentEvent( AgentEvent(
type=event_type, type=event_type,
@@ -1616,7 +1639,7 @@ class SessionManager:
data={ data={
"trigger_id": t.id, "trigger_id": t.id,
"trigger_type": t.trigger_type, "trigger_type": t.trigger_type,
"trigger_config": t.trigger_config, "trigger_config": config_out,
"name": t.description or t.id, "name": t.description or t.id,
**({"entry_node": colony_entry} if colony_entry else {}), **({"entry_node": colony_entry} if colony_entry else {}),
}, },
+78 -2
View File
@@ -468,6 +468,51 @@ async def _persist_active_triggers(session: Any, session_id: str) -> None:
logger.warning("Failed to persist active triggers for session %s", session_id, exc_info=True) logger.warning("Failed to persist active triggers for session %s", session_id, exc_info=True)
async def _emit_trigger_fired(session: Any, trigger_id: str, trigger_type: str) -> None:
"""Publish EventType.TRIGGER_FIRED and update per-session fire stats.
Called by both the timer loop and the webhook handler right after
``queen_node.inject_trigger(...)``. The event carries refreshed
``next_fire_at``/``next_fire_in`` so the UI can re-anchor its
countdown without polling, plus ``fire_count``/``last_fired_at`` for
the "fired Nx · last 2m ago" badge.
"""
now_wall = time.time()
stats_map = getattr(session, "trigger_fire_stats", None)
fire_count: int | None = None
last_fired_at: int = int(now_wall * 1000)
if stats_map is not None:
s = stats_map.setdefault(trigger_id, {"fire_count": 0, "last_fired_at": None})
s["fire_count"] = int(s.get("fire_count", 0)) + 1
s["last_fired_at"] = last_fired_at
fire_count = s["fire_count"]
bus = getattr(session, "event_bus", None)
if bus is None:
return
from framework.host.event_bus import AgentEvent, EventType
data: dict[str, Any] = {
"trigger_id": trigger_id,
"trigger_type": trigger_type,
"last_fired_at": last_fired_at,
}
if fire_count is not None:
data["fire_count"] = fire_count
mono = getattr(session, "trigger_next_fire", {}).get(trigger_id)
if mono is not None:
remaining = max(0.0, mono - time.monotonic())
data["next_fire_in"] = remaining
data["next_fire_at"] = int((now_wall + remaining) * 1000)
try:
await bus.publish(AgentEvent(type=EventType.TRIGGER_FIRED, stream_id="queen", data=data))
except Exception:
logger.warning("Failed to publish TRIGGER_FIRED for '%s'", trigger_id, exc_info=True)
async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None: async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None:
"""Start an asyncio background task that fires the trigger on a timer.""" """Start an asyncio background task that fires the trigger on a timer."""
from framework.agent_loop.agent_loop import TriggerEvent from framework.agent_loop.agent_loop import TriggerEvent
@@ -475,6 +520,24 @@ async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None
cron_expr = tdef.trigger_config.get("cron") cron_expr = tdef.trigger_config.get("cron")
interval_minutes = tdef.trigger_config.get("interval_minutes") interval_minutes = tdef.trigger_config.get("interval_minutes")
# Seed the first-fire time up front so introspection (and the UI
# countdown) have a value immediately on activation instead of only
# after the first tick. Cron uses croniter's next match; interval
# uses interval_minutes. Both use monotonic, matching route readers.
fire_times = getattr(session, "trigger_next_fire", None)
if fire_times is not None:
if cron_expr:
try:
from croniter import croniter as _croniter_seed
_first = _croniter_seed(cron_expr, datetime.now(tz=UTC)).get_next(datetime)
_first_delay = max(0.0, (_first - datetime.now(tz=UTC)).total_seconds())
except Exception:
_first_delay = 60.0
else:
_first_delay = float(interval_minutes) * 60 if interval_minutes else 60.0
fire_times[trigger_id] = time.monotonic() + _first_delay
async def _timer_loop() -> None: async def _timer_loop() -> None:
if cron_expr: if cron_expr:
from croniter import croniter from croniter import croniter
@@ -491,10 +554,21 @@ async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None
else: else:
await asyncio.sleep(float(interval_minutes) * 60) await asyncio.sleep(float(interval_minutes) * 60)
# Record next fire time for introspection (monotonic, matches routes) # Record the *subsequent* next-fire time for introspection.
# For cron we peek one step further; for interval we add
# another interval. Matches routes' monotonic clock.
fire_times = getattr(session, "trigger_next_fire", None) fire_times = getattr(session, "trigger_next_fire", None)
if fire_times is not None: if fire_times is not None:
_next_delay = float(interval_minutes) * 60 if interval_minutes else 60 if cron_expr:
try:
_peek = croniter(cron_expr, datetime.now(tz=UTC)).get_next(datetime)
_next_delay = max(
0.0, (_peek - datetime.now(tz=UTC)).total_seconds()
)
except Exception:
_next_delay = 60.0
else:
_next_delay = float(interval_minutes) * 60 if interval_minutes else 60.0
fire_times[trigger_id] = time.monotonic() + _next_delay fire_times[trigger_id] = time.monotonic() + _next_delay
# Gate on a graph being loaded # Gate on a graph being loaded
@@ -518,6 +592,7 @@ async def _start_trigger_timer(session: Any, trigger_id: str, tdef: Any) -> None
}, },
) )
await queen_node.inject_trigger(event) await queen_node.inject_trigger(event)
await _emit_trigger_fired(session, trigger_id, "timer")
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
@@ -567,6 +642,7 @@ async def _start_trigger_webhook(session: Any, trigger_id: str, tdef: Any) -> No
}, },
) )
await queen_node.inject_trigger(trigger_event) await queen_node.inject_trigger(trigger_event)
await _emit_trigger_fired(session, trigger_id, "webhook")
sub_id = bus.subscribe( sub_id = bus.subscribe(
event_types=[EventType.WEBHOOK_RECEIVED], event_types=[EventType.WEBHOOK_RECEIVED],
+5
View File
@@ -84,6 +84,11 @@ export const sessionsApi = {
`/sessions/${sessionId}/triggers/${triggerId}/deactivate`, `/sessions/${sessionId}/triggers/${triggerId}/deactivate`,
), ),
runTrigger: (sessionId: string, triggerId: string) =>
api.post<{ status: string; trigger_id: string }>(
`/sessions/${sessionId}/triggers/${triggerId}/run`,
),
colonies: (sessionId: string) => colonies: (sessionId: string) =>
api.get<{ colonies: string[] }>(`/sessions/${sessionId}/colonies`), api.get<{ colonies: string[] }>(`/sessions/${sessionId}/colonies`),
+6
View File
@@ -62,6 +62,12 @@ export interface EntryPoint {
task?: string; task?: string;
/** Seconds until the next timer fire (only present for timer entry points). */ /** Seconds until the next timer fire (only present for timer entry points). */
next_fire_in?: number; next_fire_in?: number;
/** Absolute wall-clock time of the next timer fire (epoch ms). */
next_fire_at?: number;
/** Number of times this trigger has fired during the session's lifetime. */
fire_count?: number;
/** Wall-clock time of the most recent fire (epoch ms). */
last_fired_at?: number;
} }
export interface WorkerEntry { export interface WorkerEntry {
@@ -818,11 +818,64 @@ function countdownLabel(nextFireIn: number | undefined): string | null {
: `next in ${m}m ${String(s).padStart(2, "0")}s`; : `next in ${m}m ${String(s).padStart(2, "0")}s`;
} }
/** Tick a live countdown against the server-provided absolute `next_fire_at`
* (epoch ms). Falls back to converting `next_fire_in` (seconds delta) if
* the absolute form is absent. Rolls forward by interval_minutes when
* zero is crossed so the UI keeps counting between server pushes. */
function useLiveCountdown(
nextFireAt: number | undefined,
nextFireIn: number | undefined,
isActive: boolean,
intervalMinutes: number | undefined,
): { remainingSec: number | null; firesAtMs: number | null } {
const [firesAtMs, setFiresAtMs] = useState<number | null>(null);
const [remainingSec, setRemainingSec] = useState<number | null>(null);
useEffect(() => {
if (typeof nextFireAt === "number" && nextFireAt > 0) {
setFiresAtMs(nextFireAt);
} else if (typeof nextFireIn === "number" && nextFireIn >= 0) {
setFiresAtMs(Date.now() + nextFireIn * 1000);
} else {
setFiresAtMs(null);
}
}, [nextFireAt, nextFireIn]);
useEffect(() => {
if (!isActive || firesAtMs == null) {
setRemainingSec(null);
return;
}
const tick = () => {
const diff = (firesAtMs - Date.now()) / 1000;
if (diff > 0) {
setRemainingSec(diff);
} else if (intervalMinutes) {
setFiresAtMs((prev) => (prev != null ? prev + intervalMinutes * 60 * 1000 : prev));
} else {
setRemainingSec(0);
}
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [firesAtMs, isActive, intervalMinutes]);
return { remainingSec, firesAtMs };
}
function TriggerCard({ trigger, onClick }: { trigger: GraphNode; onClick: () => void }) { function TriggerCard({ trigger, onClick }: { trigger: GraphNode; onClick: () => void }) {
const isActive = triggerIsActive(trigger); const isActive = triggerIsActive(trigger);
const schedule = scheduleLabel(trigger.triggerConfig); const schedule = scheduleLabel(trigger.triggerConfig);
const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined; const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined;
const countdown = isActive ? countdownLabel(nextFireIn) : null; const nextFireAt = trigger.triggerConfig?.next_fire_at as number | undefined;
const interval = trigger.triggerConfig?.interval_minutes as number | undefined;
const fireCount = trigger.triggerConfig?.fire_count as number | undefined;
const lastFiredAt = trigger.triggerConfig?.last_fired_at as number | undefined;
const { remainingSec } = useLiveCountdown(nextFireAt, nextFireIn, isActive, interval);
const now = useNow(1000);
const countdown = isActive && remainingSec != null ? countdownLabel(remainingSec) : null;
const agoLabel = lastFiredAt ? formatAgo(lastFiredAt, now) : null;
return ( return (
<button <button
@@ -854,6 +907,13 @@ function TriggerCard({ trigger, onClick }: { trigger: GraphNode; onClick: () =>
{countdown && ( {countdown && (
<p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p> <p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p>
)} )}
{(fireCount != null && fireCount > 0) || agoLabel ? (
<p className="text-[10px] text-muted-foreground mt-0.5 pl-8">
{fireCount != null && fireCount > 0 ? `fired ${fireCount}×` : null}
{fireCount != null && fireCount > 0 && agoLabel ? " · " : null}
{agoLabel ? `last ${agoLabel}` : null}
</p>
) : null}
</button> </button>
); );
} }
@@ -867,6 +927,31 @@ function formatCountdown(seconds: number): string {
return `${s}s`; return `${s}s`;
} }
/** Human-readable "X ago" for a wall-clock epoch ms. */
function formatAgo(epochMs: number, nowMs: number): string {
const diff = Math.max(0, Math.floor((nowMs - epochMs) / 1000));
if (diff < 5) return "just now";
if (diff < 60) return `${diff}s ago`;
const m = Math.floor(diff / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
/** Reactive Date.now() that re-renders on an interval. 1s default keeps
* countdowns smooth; consumers that only need "ago" can pass a coarser
* interval. */
function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(id);
}, [intervalMs]);
return now;
}
function TriggerDetail({ function TriggerDetail({
sessionId, sessionId,
trigger, trigger,
@@ -877,14 +962,23 @@ function TriggerDetail({
onBack: () => void; onBack: () => void;
}) { }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [runBusy, setRunBusy] = useState(false);
const [runNotice, setRunNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isActive = triggerIsActive(trigger); const isActive = triggerIsActive(trigger);
const config = (trigger.triggerConfig || {}) as Record<string, unknown>; const config = (trigger.triggerConfig || {}) as Record<string, unknown>;
const cron = config.cron as string | undefined; const cron = config.cron as string | undefined;
const interval = config.interval_minutes as number | undefined; const interval = config.interval_minutes as number | undefined;
const nextFireIn = config.next_fire_in as number | undefined; const nextFireIn = config.next_fire_in as number | undefined;
const nextFireAt = config.next_fire_at as number | undefined;
const fireCount = config.fire_count as number | undefined;
const lastFiredAt = config.last_fired_at as number | undefined;
const triggerId = trigger.id.replace(/^__trigger_/, ""); const triggerId = trigger.id.replace(/^__trigger_/, "");
const { remainingSec, firesAtMs } = useLiveCountdown(nextFireAt, nextFireIn, isActive, interval);
const now = useNow(1000);
const lastFiredAgo = lastFiredAt ? formatAgo(lastFiredAt, now) : null;
const handleToggle = async () => { const handleToggle = async () => {
if (!sessionId || busy) return; if (!sessionId || busy) return;
setBusy(true); setBusy(true);
@@ -904,6 +998,23 @@ function TriggerDetail({
} }
}; };
const handleForceRun = async () => {
if (!sessionId || runBusy) return;
setRunBusy(true);
setError(null);
setRunNotice(null);
try {
await sessionsApi.runTrigger(sessionId, triggerId);
setRunNotice("Trigger fired");
// Clear the notice after a few seconds so it doesn't linger.
setTimeout(() => setRunNotice(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunBusy(false);
}
};
const schedule = cron const schedule = cron
? cronToLabel(cron) ? cronToLabel(cron)
: interval != null : interval != null
@@ -914,7 +1025,12 @@ function TriggerDetail({
// Hide UI-synthesised fields so the user sees only real operator config. // Hide UI-synthesised fields so the user sees only real operator config.
const displayEntries = Object.entries(config).filter( const displayEntries = Object.entries(config).filter(
([k]) => k !== "next_fire_in" && k !== "entry_node", ([k]) =>
k !== "next_fire_in" &&
k !== "next_fire_at" &&
k !== "fire_count" &&
k !== "last_fired_at" &&
k !== "entry_node",
); );
return ( return (
@@ -967,12 +1083,33 @@ function TriggerDetail({
</Section> </Section>
)} )}
{isActive && nextFireIn != null && nextFireIn > 0 && ( {isActive && remainingSec != null && remainingSec > 0 && (
<Section label="Next fire"> <Section label="Next fire">
<p className="text-xs text-foreground italic">in {formatCountdown(nextFireIn)}</p> <p className="text-xs text-foreground italic">in {formatCountdown(remainingSec)}</p>
{firesAtMs != null && (
<p className="text-[10px] text-muted-foreground mt-1">
at {new Date(firesAtMs).toLocaleTimeString()}
</p>
)}
</Section> </Section>
)} )}
{(fireCount != null && fireCount > 0) || lastFiredAgo ? (
<Section label="Last fire">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-foreground">{lastFiredAgo ?? "—"}</span>
{fireCount != null && fireCount > 0 && (
<span className="text-[10px] text-muted-foreground">fired {fireCount}×</span>
)}
</div>
{lastFiredAt && (
<p className="text-[10px] text-muted-foreground mt-1">
at {new Date(lastFiredAt).toLocaleTimeString()}
</p>
)}
</Section>
) : null}
{displayEntries.length > 0 && ( {displayEntries.length > 0 && (
<Section label="Config"> <Section label="Config">
<div className="space-y-1"> <div className="space-y-1">
@@ -995,6 +1132,23 @@ function TriggerDetail({
{error && ( {error && (
<p className="text-[10.5px] text-destructive leading-snug mb-2">{error}</p> <p className="text-[10.5px] text-destructive leading-snug mb-2">{error}</p>
)} )}
{runNotice && (
<p className="text-[10.5px] text-emerald-400 leading-snug mb-2">{runNotice}</p>
)}
<button
type="button"
onClick={handleForceRun}
disabled={runBusy || !sessionId}
title="Fire this trigger once, bypassing the schedule"
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 mb-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-amber-500/15 text-amber-400 hover:bg-amber-500/25 border border-amber-500/30"
>
{runBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
{runBusy ? "Firing…" : "Force Run"}
</button>
<button <button
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
+26 -4
View File
@@ -1151,10 +1151,14 @@ export default function ColonyChat() {
setGraphNodes((prev) => setGraphNodes((prev) =>
prev.map((n) => { prev.map((n) => {
if (n.id !== `__trigger_${triggerId}`) return n; if (n.id !== `__trigger_${triggerId}`) return n;
const { next_fire_in: _, ...restConfig } = (n.triggerConfig || {}) as Record< const {
string, next_fire_in: _nfi,
unknown next_fire_at: _nfa,
> & { next_fire_in?: unknown }; ...restConfig
} = (n.triggerConfig || {}) as Record<string, unknown> & {
next_fire_in?: unknown;
next_fire_at?: unknown;
};
return { ...n, status: "pending" as NodeStatus, triggerConfig: restConfig }; return { ...n, status: "pending" as NodeStatus, triggerConfig: restConfig };
}), }),
); );
@@ -1166,6 +1170,24 @@ export default function ColonyChat() {
const triggerId = event.data?.trigger_id as string; const triggerId = event.data?.trigger_id as string;
if (triggerId) { if (triggerId) {
const nodeId = `__trigger_${triggerId}`; const nodeId = `__trigger_${triggerId}`;
// Merge refreshed fire stats + next-fire anchor into the node's
// triggerConfig so the countdown re-anchors and the card shows
// an up-to-date "fired Nx · last 2m ago" badge.
const fireCount = event.data?.fire_count as number | undefined;
const lastFiredAt = event.data?.last_fired_at as number | undefined;
const nextFireAt = event.data?.next_fire_at as number | undefined;
const nextFireIn = event.data?.next_fire_in as number | undefined;
setGraphNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const config = { ...(n.triggerConfig || {}) };
if (fireCount != null) config.fire_count = fireCount;
if (lastFiredAt != null) config.last_fired_at = lastFiredAt;
if (nextFireAt != null) config.next_fire_at = nextFireAt;
if (nextFireIn != null) config.next_fire_in = nextFireIn;
return { ...n, triggerConfig: config };
}),
);
updateGraphNodeStatus(nodeId, "complete"); updateGraphNodeStatus(nodeId, "complete");
setTimeout(() => updateGraphNodeStatus(nodeId, "running"), 1500); setTimeout(() => updateGraphNodeStatus(nodeId, "running"), 1500);
} }
+139
View File
@@ -0,0 +1,139 @@
# Autonomous Agent Research Log
Research across HN, Reddit, LinkedIn, X/Twitter for discussions about autonomous AI agents.
Format: Incremental appends with timestamp, source, and content.
---
## Research Session: 2026-04-20 10:40 PDT (Hourly Trigger)
### LinkedIn Search Results (2026-04-20 10:42 PDT)
**Post 1: Alan Roy** (1h ago)
- **Title**: "What is an autonomous agent?"
- **Content**: "Imagine work that runs itself. ⚡ Autonomous agents manage tasks on your behalf so your team can focus on what matters most."
- **Context**: Marketing post for Microsoft 365 managed services
- **Engagement**: Not visible
- **URL**: my247support.lll-ll.com
**Post 2: Stephanie Walter** (4h ago) ⭐
- **Title**: "Can your private cloud handle the liability of an autonomous agent?"
- **Content**: Broadcom launches a secure-by-default PaaS (Tanzu Platform 10.4) to move AI agents from isolated experiments into governed, mission-critical production environments.
- **Key Highlights**:
- Tanzu Platform 10.4 introduces a pre-engineered agentic runtime to enforce a hard contract between developers and infrastructure
- Deny-by-default architecture prevents autonomous agents from wandering into unauthorized data silos
- Immutable supply chain via buildpacks ensures agent containers are automatically patched
- Deep integration with VMware Cloud Foundation 9 allows elastic scaling and self-healing infrastructure
- **Tags**: #EnterpriseAI #AIStack #AgenticAI
- **Engagement**: 5 reactions, 2 comments
- **Collaborators**: Steven Dickens (HyperFRAME Research)
**Post 3: Akbar Shaik** (5h ago) ⭐⭐
- **Title**: "March 2026 changed something fundamental..."
- **Content**:
- Autonomous agents are now a **regulated surface** (EU introducing "Agentic Risk Alerts")
- Governance moved from afterthought to real-time architectural requirement
- Teams building agents like prototypes, optimizing for capability while ignoring governance layers
- "In production, an ungoverned agent isn't innovation. It's liability."
- Real shift: building systems that can be trusted when agents act
- **Engagement**: 53 reactions, 5 comments
- **Role**: Global AI Advisor & Speaker | Turning Agentic AI into Enterprise-Scale Business Impact
---
**LinkedIn Key Insights:**
1. **Liability & Governance** are top concerns - posts about secure-by-default architecture getting high engagement
2. **EU Regulation** (Agentic Risk Alerts) mentioned as paradigm shift
3. **Enterprise adoption** focus - Tanzu, VMware mentions
4. High-engagement posts focus on *trust, traceability, and regulatory compliance* rather than pure capability
### Reddit Posts (2026-04-20 ~10:40 PDT)
#### 1. ModSense AI Powered Community Health Moderation Intelligence
- **Subreddit:** r/SideProject, r/FAANGrecruiting, r/OpenAIDev, r/learnmachinelearning, r/OpenSourceeAI
- **Timestamp:** 2m ago, 4m ago, 9m ago, 11m ago, 14m ago (cross-posted across multiple subreddits)
- **Votes:** 1 | **Comments:** 0-1
- **URL:** https://github.com/ben854719/ModSense-AI-Powered-Community-Health-Moderation-Intelligence
- **Insight:** AI-powered tool for community moderation — demonstrates interest in autonomous agents for content moderation use cases
#### 2. How to better use GPT agent or better alternatives?
- **Subreddit:** r/AI_Agents
- **Timestamp:** 19m ago
- **Votes:** 1 | **Comments:** 2
- **URL:** (Reddit post)
- **Insight:** Direct question about agent usage patterns — high user demand for practical guidance on agent tools
#### 3. How to better use Agents or better alternatives?
- **Subreddit:** r/ChatGPTPromptGenius
- **Timestamp:** 19m ago
- **Votes:** 1 | **Comments:** 2
- **URL:** (Reddit post)
- **Insight:** Cross-community duplicate of agent usage question — shows broad interest beyond dedicated AI agent subreddits
#### Relevant Communities Discovered:
- **r/AI_Agents** — 351K members, 6.6K weekly contributions. "AI Agents are LLMs that have the ability to use tools or execute functions in an autonomous or semi-autonomous (human-in-the-loop) fashion"
- **r/autonomousagents** — AI-powered autonomous agents like AutoGPT focus
- **r/AgenticOps** — 26 members, new community for n8n, ComfyUI, LangChain, FlowAgent pipelines
- **r/Agent_AI** — 15K members, AI agents that can plan, execute, and learn autonomously
### Hacker News - Algolia Search Results
*Scraped: 2026-04-20 10:42 PDT | Time filter: N/A (Algolia UI limitation)*
#### Post 1: Nous - Open-Source Agent Framework
- **URL**: https://github.com/TrafficGuard/nous
- **Points**: 155 | **Comments**: 37
- **Author**: campers | **Age**: ~2 years ago
- **Summary**: TypeScript integrated agent framework combining CrewAI, OpenDevon, LangFuse concepts. Includes autonomous agent generating Python code executed via Pyodide/WebAssembly sandbox for cost efficiency. DevOps/SRE automation focus with GitLab MR AI reviewer.
#### Post 2: JetBrains Junie Autonomous AI Agent
- **URL**: https://www.youtube.com/watch?v=Ti-JGNvRDo4
- **Points**: 4 | **Comments**: 1
- **Author**: dmcg | **Age**: ~1 year ago
- **Summary**: IDE-integrated autonomous agent from JetBrains (video first look).
#### Post 3: VebGen - Autonomous AI Agent for Django
- **URL**: https://github.com/vebgenofficial/vebgen
- **Points**: 3 | **Comments**: 0
- **Author**: vebgen | **Age**: ~6 months ago
- **Summary**: Zero-token AST intelligence for Django projects. 20-year-old developer from India built entire 500KB codebase with free-tier models. Key innovation: local AST parsing eliminates LLM calls for code understanding. Dual-agent system (TARS plans, CASE executes), 70% bug fix rate, security-first architecture.
#### Key Signals from HN:
- **Interest in DevOps/SRE automation** ( Nous)
- **Domain-specific agents** (Django focus - VebGen)
- **Cost consciousness** (local/free-tier solutions)
- **IDE integration trend** (JetBrains Junie)
---
### X/Twitter (2026-04-20 10:50 PDT)
*X/Twitter scrape unavailable in this session — blocked by authentication requirement.*
**Note:** X requires login to view search results. For future hourly runs, consider:
- Using a authenticated browser session
- Alternative: Search via Nitter instances or syndication feeds
---
## Session Summary (2026-04-20 10:40-10:50 PDT)
| Source | Posts Found | Key Themes |
|--------|-------------|------------|
| **Hacker News** | 3 | DevOps/SRE (Nous), Domain-specific (VebGen), IDE integration (Junie) |
| **Reddit** | 3 | Content moderation, practical agent usage, community discovery |
| **LinkedIn** | 3 | Governance/liability, EU regulation, enterprise adoption |
| **X/Twitter** | 0 | Auth required — needs session cookie |
**Total Posts Captured:** 9
**Cross-Platform Themes:**
1. **Governance & Trust** — LinkedIn high-engagement content frames agents as liability/regulatory concerns
2. **Domain-specific agents** — Django, SRE, content moderation specific solutions gaining traction
3. **Cost consciousness** — Local/free-tier solutions (VebGen, Pyodide sandbox)
4. **Practical demand** — Users asking "how do I actually use agents?" vs "what can they do?"
**Next Run:** Scheduled hourly (60 min interval)