feat: improvements for scheduler
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user