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