Stop idle goal polling

Publish goal revisions through the shared state monitor and refresh the Goal UI only when the selected chat or goal state changes. Keep the local elapsed-time clock, remove recurring API requests, and cover the state-push contract with focused regressions.

Alessandro committed Aug 11, 2026 at 23:52 UTC e8e566d221d654edc15e4e076a9dc992c2d483ba
6 files changed +95 -13
plugins/_goal/AGENTS.md
+3
@@ -27,6 +27,9 @@
27 - `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
28 - While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination.
29 - Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
30 +- Goal state changes publish a context revision through the shared state-push
31 + lifecycle; the WebUI refreshes on context or revision changes and never polls
32 + the Goal API while idle.
33
34 ## Work Guidance
35
plugins/_goal/extensions/webui/apply_snapshot_before/refresh-goal.js new
+16
@@ -0,0 +1,16 @@
1 +import { store as goalStore } from "/plugins/_goal/webui/goal-store.js";
2 +
3 +let lastContextId = "";
4 +let lastRevision = null;
5 +
6 +export default async function refreshGoalOnRevision(ctx) {
7 + const snapshot = ctx?.snapshot;
8 + const contextId = String(snapshot?.context || "");
9 + const activeContext = (snapshot?.contexts || []).find(item => item?.id === contextId);
10 + const revision = activeContext?._goal_revision ?? null;
11 +
12 + if (contextId === lastContextId && revision === lastRevision) return;
13 + lastContextId = contextId;
14 + lastRevision = revision;
15 + await goalStore.refresh(true);
16 +}
plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html
-1
@@ -5,7 +5,6 @@
5 <div x-data
6 class="goal-strip-root"
7 x-create="$store.goalBar.onMount()"
8 - x-init="$watch('$store.chats.selected', () => $store.goalBar.refresh(true))"
8 x-destroy="$store.goalBar.cleanup()">
9 <template x-if="$store.goalBar && $store.goalBar.visible">
10 <div class="goal-strip" :class="`is-${$store.goalBar.goal?.status || 'active'}`">
plugins/_goal/tests/test_goal_plugin.py
+51
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import uuid
4 +from pathlib import Path
5 from types import SimpleNamespace
6
7 import pytest
@@ -57,6 +58,56 @@ def test_goal_storage_round_trip(context_id: str):
58 assert goal.get_goal(context_id) is None
59
60
61 +def test_goal_changes_publish_state_revision(context_id: str, monkeypatch):
62 + from agent import AgentContext
63 + from helpers import state_monitor_integration
64 +
65 + revisions = iter([1.0, 2.0, 3.0])
66 + output_data = {}
67 + dirty = []
68 + context = SimpleNamespace(
69 + set_output_data=lambda key, value: output_data.__setitem__(key, value)
70 + )
71 + monkeypatch.setattr(AgentContext, "get", lambda _context_id: context)
72 + monkeypatch.setattr(goal.time, "time", lambda: next(revisions))
73 + monkeypatch.setattr(
74 + state_monitor_integration,
75 + "mark_dirty_for_context",
76 + lambda context_id, *, reason: dirty.append((context_id, reason)),
77 + )
78 +
79 + goal.create_goal(context_id, "Publish changes")
80 + goal.update_goal(context_id, status="paused")
81 + goal.delete_goal(context_id)
82 +
83 + assert output_data["_goal_revision"] == 3.0
84 + assert dirty == [(context_id, "plugins._goal")] * 3
85 +
86 +
87 +def test_goal_webui_uses_state_revisions_instead_of_polling():
88 + plugin_root = Path(__file__).resolve().parents[1]
89 + store = (plugin_root / "webui" / "goal-store.js").read_text()
90 + strip = (
91 + plugin_root
92 + / "extensions"
93 + / "webui"
94 + / "chat-input-progress-start"
95 + / "goal-strip.html"
96 + ).read_text()
97 + refresh = (
98 + plugin_root
99 + / "extensions"
100 + / "webui"
101 + / "apply_snapshot_before"
102 + / "refresh-goal.js"
103 + ).read_text()
104 +
105 + assert "setInterval(() => this.refresh" not in store
106 + assert "$watch('$store.chats.selected'" not in strip
107 + assert "_goal_revision" in refresh
108 + assert "goalStore.refresh(true)" in refresh
109 +
110 +
111 def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
112 created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
113 assert created["effects"][0]["message"] == "Goal set."
plugins/_goal/tools/goal.py
+21 -1
@@ -2,6 +2,7 @@ from __future__ import annotations
2
3 import json
4 import re
5 +import time
6 from datetime import datetime, timezone
7 from pathlib import Path
8 from typing import Any
@@ -141,7 +142,9 @@ def update_goal(
142
143
144 def delete_goal(context_id: str) -> None:
144 - files.delete_file(_goal_path(_require_context_id(context_id)))
145 + context_id = _require_context_id(context_id)
146 + files.delete_file(_goal_path(context_id))
147 + _notify_goal_changed(context_id)
148
149
150 def public_goal(goal: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -182,6 +185,23 @@ def _write_goal(goal: dict[str, Any]) -> None:
185 _goal_path(str(goal["context_id"])),
186 json.dumps(public_goal(goal), indent=2, ensure_ascii=False) + "\n",
187 )
188 + _notify_goal_changed(str(goal["context_id"]))
189 +
190 +
191 +def _notify_goal_changed(context_id: str) -> None:
192 + from agent import AgentContext
193 +
194 + context = AgentContext.get(context_id)
195 + if context is None:
196 + return
197 + context.set_output_data("_goal_revision", time.time())
198 +
199 + try:
200 + from helpers.state_monitor_integration import mark_dirty_for_context
201 +
202 + mark_dirty_for_context(context_id, reason="plugins._goal")
203 + except Exception:
204 + pass
205
206
207 def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:
plugins/_goal/webui/goal-store.js
+4 -11
@@ -15,7 +15,6 @@ const model = {
15 editing: false,
16 draft: "",
17 lastContextId: "",
18 - intervalId: null,
18 clockIntervalId: null,
19 goalChangedHandler: null,
20 now: Date.now(),
@@ -82,17 +81,15 @@ const model = {
81 document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
82 this.goalChangedHandler = (event) => {
83 const detail = event?.detail || {};
85 - if (detail.goal === null) {
86 - this.goal = null;
87 - }
88 - void this.refresh(true);
84 + if (detail.context_id && detail.context_id !== this.contextId) return;
85 + this.goal = detail.goal || null;
86 + this.now = Date.now();
87 + if (!this.goal) this.editing = false;
88 };
89 window.addEventListener("goal:changed", this.goalChangedHandler);
90 this.clockIntervalId = window.setInterval(() => {
91 this.now = Date.now();
92 }, 1000);
94 - this.intervalId = window.setInterval(() => this.refresh(), 3000);
95 - void this.refresh(true);
93 },
94
95 cleanup() {
@@ -100,14 +97,10 @@ const model = {
97 if (this.goalChangedHandler) {
98 window.removeEventListener("goal:changed", this.goalChangedHandler);
99 }
103 - if (this.intervalId) {
104 - window.clearInterval(this.intervalId);
105 - }
100 if (this.clockIntervalId) {
101 window.clearInterval(this.clockIntervalId);
102 }
103 this.goalChangedHandler = null;
110 - this.intervalId = null;
104 this.clockIntervalId = null;
105 },
106