refactor(scheduler): resolve live channel instance for delivery and use one global task store

Route scheduled delivery through the live ChannelManager instance (by instance_id, then the trusted recipient directory) instead of a bare create_channel() singleton, which fixed WeChat tasks stuck as "not ready" after restart in multi-agent mode (#3120). A shared common/channel_registry avoids the __main__-vs-app duplicate module hiding the manager from the scheduler.

Consolidate per-Agent tasks.json into a single global store; each task carries agent_id, and IM-task ownership is derived live from its delivery instance's binding (effective_task_agent_id) so re-binding a channel moves the task with no rewrite. Legacy per-Agent stores migrate once at startup; recipients.json moves under scheduler/.

Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
zhayujieandcowagent committed 2026-09-07 18:20:41 +08:00
1 parent bcb437c676
commit 87706beee7
14 files changed
+1302 -179

No files matched your search

+446 -114
View File
@@ -11,14 +11,38 @@ from common.utils import expand_path
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
# Backward-compatible aliases for the configured default agent.
# One global scheduler for the whole process. Tasks are no longer sharded per
# Agent workspace; the single store at ``scheduler_file_global()`` holds every
# Agent's tasks, each tagged with its own ``agent_id`` (the Agent it runs as) and
# ``instance_id`` (the channel login it delivers through). One service scans that
# store; before running a task it scopes the runtime identity to the task's
# ``agent_id``. This decouples task ownership from a channel instance's current
# Agent binding: re-binding an instance moves no task data.
_scheduler_service = None
_task_store = None
_scheduler_services: Dict[str, object] = {}
_task_stores: Dict[str, object] = {}
_recipient_stores: Dict[str, object] = {}
# The recipient directory is shared across every Agent (state_dir
# .scheduler_recipients_file), a single process-wide instance not one per workspace.
_recipient_store = None
# Module-level lock to guard idempotent initialization across threads
_init_lock = threading.RLock()
# One-shot fold of per-Agent ``scheduler/tasks.json`` files into the global store.
_legacy_stores_migrated = False
def _get_shared_recipient_store():
"""The one recipient directory every Agent reads and writes.
Lives beside the global tasks.json at ``scheduler/recipients.json``; shared,
not per Agent, so any Agent's console can target anyone the instance has met.
"""
global _recipient_store
if _recipient_store is None:
with _init_lock:
if _recipient_store is None:
from agent.tools.scheduler.recipient_store import RecipientStore
from common.state_dir import scheduler_recipients_file
_recipient_store = RecipientStore(str(scheduler_recipients_file()))
return _recipient_store
def _resolve_workspace(workspace_root: str = None, agent_id: str = None):
@@ -37,77 +61,237 @@ def _resolve_workspace(workspace_root: str = None, agent_id: str = None):
return str(state_root(identity))
def _migrate_legacy_task_stores(task_store) -> None:
"""Fold per-Agent ``scheduler/tasks.json`` files into the one global store.
The global file *is* the default Agent's historical path
(``shared_root/scheduler/tasks.json``), so those tasks stay put — we only
stamp a missing ``agent_id``. Every other Agent's workspace file is imported
once (skipping ids that already exist globally) and then renamed
``.migrated`` so a later boot does not re-import them.
Re-binding a channel instance never calls this and never moves a task:
ownership lives on the task's ``agent_id`` field, not in the file path.
"""
global _legacy_stores_migrated
if _legacy_stores_migrated:
return
try:
from agent.registry import get_agent_registry
from common.state_dir import scheduler_file, scheduler_file_global
registry = get_agent_registry()
default_id = registry.default_agent_id
global_path = os.path.realpath(str(scheduler_file_global()))
tasks = task_store.load_tasks()
changed = False
# Stamp missing owner on tasks already in the global file.
for task in tasks.values():
if not (task.get("agent_id") or "").strip():
task["agent_id"] = default_id
changed = True
for profile in registry.list(include_disabled=True):
legacy_path = os.path.realpath(str(scheduler_file(base=profile.workspace)))
if legacy_path == global_path:
continue
if not os.path.exists(legacy_path):
continue
try:
legacy_store = type(task_store)(legacy_path)
incoming = legacy_store.load_tasks()
except Exception as e:
logger.warning(
f"[Scheduler] Skip legacy store {legacy_path}: {e}"
)
continue
imported = 0
skipped = 0
for task_id, task in incoming.items():
if task_id in tasks:
skipped += 1
continue
task = dict(task)
if not (task.get("agent_id") or "").strip():
task["agent_id"] = profile.id
tasks[task_id] = task
imported += 1
changed = True
migrated_path = legacy_path + ".migrated"
try:
os.replace(legacy_path, migrated_path)
except OSError as e:
logger.warning(
f"[Scheduler] Imported {imported} task(s) from {legacy_path} "
f"but could not rename it aside: {e}"
)
else:
logger.info(
f"[Scheduler] Migrated {imported} task(s) from {legacy_path} "
f"into the global store"
+ (f" (skipped {skipped} duplicate id(s))" if skipped else "")
)
if changed:
task_store.save_tasks(tasks)
_legacy_stores_migrated = True
except Exception as e:
logger.warning(f"[Scheduler] Legacy task-store migration failed: {e}")
def _resolve_instance_agent_id(instance_id: str) -> str:
"""The Agent a channel instance is currently bound to, or "" for none.
Read live from the channel-instance config (team.json) rather than cached on
the task, so re-binding an instance to another Agent takes effect on the next
tick with no data migration. An unbound / legacy single-instance channel
(instance_id == channel_type, no explicit binding) returns "".
"""
instance_id = (instance_id or "").strip()
if not instance_id:
return ""
try:
from channel.channel_instances import get_instance
from config import conf
inst = get_instance(conf(), instance_id)
if inst and (inst.agent_id or "").strip():
return inst.agent_id.strip()
except Exception as e:
logger.debug(
f"[Scheduler] Could not resolve agent for instance '{instance_id}': {e}"
)
return ""
def effective_task_agent_id(task: dict, registry=None, validate: bool = False) -> str:
"""Who a task belongs to *right now*.
This is the one place that decides ownership so execution and the console
never disagree. For an IM task the delivery instance's current binding wins
(re-binding a channel silently moves its tasks); otherwise the stored
``agent_id`` is used; falling back to the default Agent.
``validate`` gates whether an id must resolve to an *enabled* Agent:
* execution passes ``validate=True`` so a tick never runs as a removed or
disabled Agent (it degrades to the default instead);
* display callers (the list endpoint / ``list_tasks`` bucketing) leave it
False so a task keeps showing its stored owner even in contexts without
a live registry (e.g. unit tests) — the id is a label there, not an
identity to assume.
``registry`` is optional; when None it is resolved lazily and, if that
fails, validation is skipped and the default falls back to "".
"""
if registry is None:
try:
from agent.registry import get_agent_registry
registry = get_agent_registry()
except Exception:
registry = None
def _take(agent_id: str) -> str:
agent_id = (agent_id or "").strip()
if not agent_id:
return ""
if not validate or registry is None:
return agent_id
try:
return registry.get(agent_id, require_enabled=True).id
except Exception:
return ""
if isinstance(task, dict):
action = task.get("action") if isinstance(task.get("action"), dict) else {}
instance_id = (action.get("instance_id") or "").strip()
channel_type = (action.get("channel_type") or "").strip()
# Web targets a chat session, not a bound external instance, so it keeps
# using the stored agent_id; only external IM instances derive live.
if instance_id and channel_type not in ("", "web"):
derived = _take(_resolve_instance_agent_id(instance_id))
if derived:
return derived
stored = _take(task.get("agent_id"))
if stored:
return stored
if registry is not None:
return registry.default_agent_id
return ""
def _resolve_task_agent_id(agent_bridge, task: dict) -> str:
"""The Agent a task runs as, resolved against the live registry.
Thin wrapper over :func:`effective_task_agent_id` that validates (so a tick
never runs as a removed/disabled Agent) and always yields a concrete Agent
id (the default when nothing else resolves).
"""
registry = agent_bridge.agent_registry
return (
effective_task_agent_id(task, registry, validate=True)
or registry.default_agent_id
)
def init_scheduler(agent_bridge, workspace_root: str = None, agent_id: str = None) -> bool:
"""
Initialize scheduler service (idempotent).
Initialize the one global scheduler service (idempotent).
Safe to call multiple times and from multiple threads: only the first
successful call creates the singleton ``SchedulerService`` + background
scanning thread. Subsequent calls return immediately.
Safe to call any number of times and from any Agent: the scheduler is now a
single process-wide service over one global task store, so the first call
builds it and every later call (per-Agent warmup, roster reload, a lazy
first-message init) is a no-op that just returns True. The ``workspace_root``
/ ``agent_id`` arguments are ignored — they only remain so existing per-Agent
call sites need no change.
Args:
agent_bridge: AgentBridge instance
Returns:
True if scheduler is initialized (newly created or already running)
True if the scheduler is initialized (newly created or already running)
"""
global _scheduler_service, _task_store
workspace_root = _resolve_workspace(workspace_root, agent_id)
agent_id = agent_bridge.agent_registry.get(agent_id).id
# Fast path: already initialized and running
service = _scheduler_services.get(workspace_root)
if service is not None and getattr(service, "running", False):
# Fast path: already running.
if _scheduler_service is not None and getattr(_scheduler_service, "running", False):
return True
with _init_lock:
# Re-check under the lock to avoid races where multiple threads
# passed the fast-path check before any of them acquired the lock.
service = _scheduler_services.get(workspace_root)
if service is not None and getattr(service, "running", False):
if _scheduler_service is not None and getattr(_scheduler_service, "running", False):
return True
try:
from agent.tools.scheduler.task_store import TaskStore
from agent.tools.scheduler.recipient_store import RecipientStore
from agent.tools.scheduler.scheduler_service import SchedulerService
from common.state_dir import scheduler_file_global
from common.state_dir import scheduler_file, scheduler_recipients_file
store_path = str(scheduler_file_global())
task_store = TaskStore(store_path)
_task_store = task_store
_migrate_legacy_task_stores(task_store)
logger.debug(f"[Scheduler] Global task store initialized: {store_path}")
store_path = str(scheduler_file(base=workspace_root))
_get_shared_recipient_store()
# Create task store (reuse if already created)
task_store = _task_stores.get(workspace_root)
if task_store is None:
task_store = TaskStore(store_path)
_task_stores[workspace_root] = task_store
logger.debug(f"[Scheduler] Task store initialized: {store_path}")
recipient_store = _recipient_stores.get(workspace_root)
if recipient_store is None:
recipient_store = RecipientStore(
str(scheduler_recipients_file(base=workspace_root))
)
_recipient_stores[workspace_root] = recipient_store
# Create execute callback. Returns True on success, False to ask
# the scheduler to retry on the next tick (e.g. channel not yet
# ready right after process start).
# Execute callback. Returns True on success, False to retry next tick
# (e.g. channel not ready just after start). The Agent identity is
# taken from the task itself (not a closure), so one service runs
# tasks belonging to any Agent under the right workspace/memory/tools.
def execute_task_callback(task: dict):
# Scheduler threads carry no identity of their own. Binding it
# here is the counterpart to chat_channel._handle: leaf code
# reached from a task (ToolManager, tmp_dir, memory) then lands
# in this Agent's workspace rather than the default one's.
from common.runtime_identity import identity_scope
agent_id = _resolve_task_agent_id(agent_bridge, task)
try:
with identity_scope(agent_id=agent_id):
action = task.get("action", {})
action_type = action.get("type")
channel_type = _primary_channel_type(action.get("channel_type"))
receiver = action.get("receiver", "")
instance_id = action.get("instance_id") or ""
if not _is_channel_ready(channel_type, receiver, agent_id):
if not _is_channel_ready(channel_type, receiver, agent_id, instance_id):
logger.warning(
f"[Scheduler] Task {task.get('id')}: channel "
f"'{channel_type}' not ready for receiver={receiver} "
@@ -130,18 +314,11 @@ def init_scheduler(agent_bridge, workspace_root: str = None, agent_id: str = Non
logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}")
return False
# Create scheduler service
service = SchedulerService(task_store, execute_task_callback)
service.start()
_scheduler_services[workspace_root] = service
if agent_id == agent_bridge.agent_registry.default_agent_id:
_scheduler_service = service
_task_store = task_store
_scheduler_service = service
logger.info(
f"[Scheduler] Service initialized for agent={agent_id}, "
f"workspace={workspace_root}"
)
logger.info(f"[Scheduler] Global service initialized, store={store_path}")
return True
except Exception as e:
@@ -165,8 +342,150 @@ def _primary_channel_type(raw) -> str:
return first or "unknown"
def _is_live_channel_instance(channel) -> bool:
"""True when *channel* is one ChannelManager is actually running.
Distinguishes a real, started instance (which reflects real login/token
state) from the bare singleton ``create_channel`` hands back when nothing is
running. Only for the former does an empty token map mean "not ready"; for
the latter it means "we can't tell", so callers must not hard-block on it.
"""
try:
from common.channel_registry import get_channel_manager
manager = get_channel_manager()
if manager is None:
return False
for _name, ch in manager.find_channels_by_type(getattr(channel, "channel_type", "")):
if ch is channel:
return True
except Exception:
return False
return False
def _channel_holds_receiver(channel, channel_type: str, receiver: str) -> bool:
"""Whether *channel* already knows how to reach *receiver*.
Used to disambiguate when several running instances of one type exist and
the task carries no instance_id: the right one is the login that actually
holds this receiver's context token. Only weixin keeps such per-receiver
tokens; for other types we can't tell, so we don't claim a match.
"""
if channel_type == "weixin":
tokens = getattr(channel, "_context_tokens", None)
return bool(tokens and receiver in tokens)
return False
def _recover_instance_id_from_directory(channel_type: str, receiver: str) -> str:
"""The instance that first saw *receiver*, per the recipient directory.
A receiver id is only meaningful within the instance that issued it — a
Feishu ``open_id`` is scoped to one app, so sending it through a different
Feishu instance fails with ``open_id cross app``. When a task carries no
(or a stale) instance_id, the recipient directory — keyed by
``(instance_id, receiver)`` — is the authoritative place to recover the
right instance. Returns "" when unknown.
"""
receiver = str(receiver or "").strip()
channel_type = str(channel_type or "").strip()
if not receiver:
return ""
try:
store = _get_shared_recipient_store()
for entry in store.list():
if (
entry.get("channel_type") == channel_type
and entry.get("receiver") == receiver
):
iid = str(entry.get("instance_id") or "").strip()
if iid:
return iid
except Exception as e:
logger.debug(f"[Scheduler] recipient directory lookup failed: {e}")
return ""
def _resolve_delivery_channel(channel_type: str, instance_id: str = "", receiver: str = ""):
"""The channel object a scheduled task should send through.
Prefers the *live running instance* held by ChannelManager, looked up by
instance_id. That matters because a running channel carries state a freshly
built one does not: in-memory login/context tokens, an open websocket, the
per-instance credentials it started with. Building a bare
``create_channel(channel_type)`` instead would grab whichever instance the
singleton cache happens to hold — a never-started empty object once the real
bot runs as a channel_instance, which is exactly the delivery bug this avoids.
Resolution order:
1. Exact match by instance_id (or legacy instance_id == channel_type).
2. Task carries no (or a stale) instance_id — recover the right live
instance without ever sending to the wrong login:
a. Ask the recipient directory which instance first saw this
receiver, then use that instance. This is what keeps an
app-scoped id (a Feishu open_id) from being sent through a
different app instance (``open_id cross app``).
b. Else pick the instance already holding the receiver's token
(weixin keeps per-receiver tokens).
c. Else, only when exactly one instance of the type runs, use it.
With several running instances and no way to tell them apart we
refuse to guess (returning that lone-or-none live instance),
since a wrong pick delivers to the wrong account.
3. Fall back to ``create_channel`` (legacy single-instance) only when no
manager or no running instance of the type exists.
"""
instance_id = str(instance_id or "").strip()
receiver = str(receiver or "").strip()
try:
from common.channel_registry import get_channel_manager
manager = get_channel_manager()
if manager is not None:
# (1) Exact key: instance_id, or channel_type for legacy singletons.
lookup = instance_id or channel_type
live = manager.get_channel(lookup)
if live is not None:
return live
candidates = manager.find_channels_by_type(channel_type)
if candidates:
# (2a) Authoritative: the directory knows which instance owns
# this receiver. Resolve to that exact instance.
recovered = _recover_instance_id_from_directory(channel_type, receiver)
if recovered:
live = manager.get_channel(recovered)
if live is not None:
logger.info(
f"[Scheduler] Recovered instance '{recovered}' for "
f"{channel_type} receiver={receiver} from recipient directory"
)
return live
# (2b) Token-holder (weixin): the login that already knows them.
if receiver:
for _name, ch in candidates:
if _channel_holds_receiver(ch, channel_type, receiver):
return ch
# (2c) Only safe to auto-pick when the type runs a single
# instance. Several instances with app-scoped receiver ids
# (Feishu open_id) must not be guessed — that would deliver to
# the wrong app. Leave it to the bare fallback / hard failure.
if len(candidates) == 1:
return candidates[0][1]
logger.warning(
f"[Scheduler] {channel_type} has {len(candidates)} running "
f"instances but the task named none and receiver={receiver} "
f"is not in the recipient directory; cannot pick safely"
)
except Exception as e:
logger.debug(f"[Scheduler] No live channel for instance '{instance_id}': {e}")
from channel.channel_factory import create_channel
return create_channel(channel_type)
def _is_channel_ready(
channel_type: str, receiver: str, agent_id: str = None
channel_type: str, receiver: str, agent_id: str = None, instance_id: str = ""
) -> bool:
"""Best-effort readiness probe for outbound channels.
@@ -174,18 +493,31 @@ def _is_channel_ready(
logged in, web session has no polling queue), so the scheduler can
defer instead of consuming the task. Unknown channels return True
to preserve previous behaviour.
Probes the same live instance delivery will use, so a "ready" answer means
*that* instance is ready, not some other login of the same channel type.
"""
if not channel_type or channel_type == "unknown":
return True
try:
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
channel = _resolve_delivery_channel(channel_type, instance_id, receiver)
if channel is None:
return False
if channel_type == "weixin":
tokens = getattr(channel, "_context_tokens", None)
if not tokens or receiver not in tokens:
# The token check only means something for the *running* login.
# If we're staring at a bare, never-started singleton (no live
# instance in the manager), its token map is empty no matter
# what — blocking here would defer forever (issue #3120). Fall
# back to the pre-check behaviour and let the send attempt run.
if not _is_live_channel_instance(channel):
logger.debug(
"[Scheduler] weixin readiness inconclusive (no live "
f"instance for receiver={receiver}); allowing send attempt"
)
return True
return False
return True
@@ -204,66 +536,66 @@ def _is_channel_ready(
def get_task_store(workspace_root: str = None, agent_id: str = None):
"""Get the task store owned by one agent workspace."""
workspace_root = _resolve_workspace(workspace_root, agent_id)
return _task_stores.get(workspace_root)
"""The one global task store. ``workspace_root``/``agent_id`` are ignored
(kept for call-site compatibility); tasks are filtered by ``agent_id`` at
the query, not by which store they live in."""
if _task_store is not None:
return _task_store
# Lazily build the store even if the service hasn't started yet, so tools /
# handlers that only read or write tasks work before scheduler warmup.
with _init_lock:
if _task_store is not None:
return _task_store
try:
from agent.tools.scheduler.task_store import TaskStore
from common.state_dir import scheduler_file_global
globals()["_task_store"] = TaskStore(str(scheduler_file_global()))
_migrate_legacy_task_stores(globals()["_task_store"])
except Exception as e:
logger.error(f"[Scheduler] Failed to open global task store: {e}")
return None
return _task_store
def get_scheduler_service(workspace_root: str = None, agent_id: str = None):
"""Get the scheduler service owned by one agent workspace."""
workspace_root = _resolve_workspace(workspace_root, agent_id)
return _scheduler_services.get(workspace_root)
"""The one global scheduler service (``workspace_root``/``agent_id`` ignored)."""
return _scheduler_service
def get_recipient_store(workspace_root: str = None, agent_id: str = None):
"""Get the trusted recipient directory owned by one agent workspace."""
workspace_root = _resolve_workspace(workspace_root, agent_id)
return _recipient_stores.get(workspace_root)
"""Get the shared trusted recipient directory.
Shared across Agents, so the ``workspace_root``/``agent_id`` arguments are
ignored; they are kept only so existing call sites need no change.
"""
return _get_shared_recipient_store()
def reset_scheduler_services(stop: bool = True) -> None:
"""Stop and forget all scheduler services, primarily for reloads/tests."""
global _scheduler_service, _task_store
"""Stop and forget the global scheduler service, primarily for reloads/tests."""
global _scheduler_service, _task_store, _recipient_store, _legacy_stores_migrated
with _init_lock:
if stop:
for service in list(_scheduler_services.values()):
try:
service.stop()
except Exception:
pass
_scheduler_services.clear()
_task_stores.clear()
_recipient_stores.clear()
if stop and _scheduler_service is not None:
try:
_scheduler_service.stop()
except Exception:
pass
_recipient_store = None
_scheduler_service = None
_task_store = None
_legacy_stores_migrated = False
def stop_scheduler(agent_id: str = None, workspace_root: str = None) -> bool:
"""Stop and forget a single Agent's scheduler service, leaving the rest of
the fleet running. Used when an Agent is archived/removed so a roster edit
does not have to reset every scheduler. Returns True if one was found.
"""No-op under the global scheduler.
The actual stop is detached to a daemon thread: ``service.stop()`` joins the
scan loop (up to a few seconds), and a roster edit should not block the HTTP
response on that. We drop the service from the registry synchronously so it
is immediately forgotten; the thread just winds the loop down."""
workspace_root = _resolve_workspace(workspace_root, agent_id)
with _init_lock:
service = _scheduler_services.pop(workspace_root, None)
_task_stores.pop(workspace_root, None)
if service is None:
return False
threading.Thread(
target=lambda: _safe_stop(service), daemon=True, name="scheduler-stop"
).start()
return True
def _safe_stop(service) -> None:
try:
service.stop()
except Exception:
pass
Tasks are no longer sharded per Agent, so removing one Agent from the roster
does not stop any scheduler — the single global service keeps running the
remaining Agents' tasks. A removed/disabled Agent's tasks simply stop
matching at execution time (they fall back to the default owner or are
skipped). Kept for call-site compatibility; always returns False."""
return False
def _remember_delivered_output(
@@ -406,10 +738,9 @@ def _execute_agent_task(task: dict, agent_bridge, agent_id: str = None) -> bool:
)
return True
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
channel = _resolve_delivery_channel(channel_type, action.get("instance_id") or "", receiver)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
logger.error(f"[Scheduler] Failed to resolve channel: {channel_type}")
return False
if channel_type == "web" and hasattr(channel, 'request_to_session'):
@@ -495,13 +826,11 @@ def _execute_send_message(task: dict, agent_bridge, agent_id: str = None) -> boo
# Create reply
reply = Reply(ReplyType.TEXT, content)
# Get channel and send
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
# Get the live instance for this task and send through it.
channel = _resolve_delivery_channel(channel_type, action.get("instance_id") or "", receiver)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
logger.error(f"[Scheduler] Failed to resolve channel: {channel_type}")
return False
if channel_type == "web" and hasattr(channel, 'request_to_session'):
@@ -575,10 +904,9 @@ def _execute_tool_call(task: dict, agent_bridge, agent_id: str = None) -> bool:
reply = Reply(ReplyType.TEXT, content)
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
channel = _resolve_delivery_channel(channel_type, action.get("instance_id") or "", receiver)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
logger.error(f"[Scheduler] Failed to resolve channel: {channel_type}")
return False
if channel_type == "web" and request_id and hasattr(channel, 'request_to_session'):
@@ -660,10 +988,9 @@ def _execute_skill_call(task: dict, agent_bridge, agent_id: str = None) -> bool:
if result_prefix:
content = f"{result_prefix}\n\n{content}"
from channel.channel_factory import create_channel
channel = create_channel(channel_type)
channel = _resolve_delivery_channel(channel_type, action.get("instance_id") or "", receiver)
if not channel:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
logger.error(f"[Scheduler] Failed to resolve channel: {channel_type}")
return False
if channel_type == "web" and hasattr(channel, 'request_to_session'):
@@ -720,10 +1047,15 @@ def attach_scheduler_to_tool(tool, context: Context = None):
# persist ephemeral credentials; the channel keeps enforcing its own
# authentication and readiness rules at delivery time.
if recipient_store and channel_type != "web":
# instance_id is what a scheduled delivery must route back through:
# two instances of one channel type have separate logins and receiver
# id spaces. It rides on the inbound context; when absent (legacy
# single-instance channel) the store falls back to the channel type.
recipient_store.remember(
channel_type,
context.get("receiver"),
name=tool._get_receiver_name(context),
is_group=context.get("isgroup", False),
session_id=context.get("session_id"),
instance_id=context.get("instance_id") or "",
)
+71 -16
View File
@@ -16,6 +16,21 @@ class RecipientStore:
The store deliberately contains no access tokens or channel credentials.
Channel implementations remain responsible for authentication and their
normal outbound readiness checks.
The directory is shared across Agents, but keyed by ``(instance_id, receiver)``
rather than by ``(channel_type, receiver)``. A channel *type* can run several
*instances* (two Feishu bots, two WeChat logins), each with its own login,
tokens and — crucially — its own id space for receivers: the same receiver
string on two WeChat instances is two different people. Delivery therefore has
to go back out through the exact instance that first saw the contact, so the
instance is part of the identity, not just the type.
Legacy single-instance installs use ``instance_id == channel_type`` (that is
how the launcher already synthesizes them), so an old entry saved under just a
channel type keeps resolving unchanged.
The owning Agent of a scheduled delivery is derived from the recipient's
channel instance (an instance binds to one Agent), so it is not stored here.
"""
def __init__(self, store_path: str) -> None:
@@ -24,8 +39,12 @@ class RecipientStore:
self.store_path.parent.mkdir(parents=True, exist_ok=True)
@staticmethod
def _key(channel_type: str, receiver: str) -> str:
return f"{channel_type}\0{receiver}"
def _key(instance_id: str, receiver: str) -> str:
# ``:`` reads cleanly in the on-disk JSON. Both an instance_id and a
# receiver id can themselves carry a colon (feishu group ids do), so the
# key is only ever a joined form; downstream code uses the structured
# fields, never a split of this key.
return f"{instance_id}:{receiver}"
def _load_unlocked(self) -> Dict[str, dict]:
if not self.store_path.exists():
@@ -47,6 +66,19 @@ class RecipientStore:
os.fsync(handle.fileno())
os.replace(temporary, self.store_path)
@staticmethod
def _normalize(entry: dict) -> dict:
"""Fill fields an older on-disk entry may predate, without rewriting it.
``instance_id`` predates multi-instance and defaults to the channel type,
which is exactly the legacy single-instance id, so an old entry resolves
and delivers the same as before.
"""
result = dict(entry)
if not result.get("instance_id"):
result["instance_id"] = result.get("channel_type", "")
return result
def remember(
self,
channel_type: str,
@@ -55,13 +87,18 @@ class RecipientStore:
name: str = "",
is_group: bool = False,
session_id: str = "",
instance_id: str = "",
) -> Optional[dict]:
channel_type = str(channel_type or "").strip()
receiver = str(receiver or "").strip()
# A missing instance_id means a legacy single-instance channel, whose id
# is the channel type; this keeps old callers and old data on one path.
instance_id = str(instance_id or "").strip() or channel_type
if not channel_type or not receiver or channel_type in {"unknown", "web"}:
return None
entry = {
"channel_type": channel_type,
"instance_id": instance_id,
"receiver": receiver,
"name": str(name or receiver),
"is_group": bool(is_group),
@@ -70,26 +107,44 @@ class RecipientStore:
}
with self._lock:
recipients = self._load_unlocked()
key = self._key(channel_type, receiver)
key = self._key(instance_id, receiver)
previous = recipients.get(key)
stable_fields = ("channel_type", "receiver", "name", "is_group", "session_id")
if previous and all(previous.get(field) == entry[field] for field in stable_fields):
try:
last_seen = datetime.fromisoformat(previous["last_seen_at"])
if datetime.now(timezone.utc) - last_seen < timedelta(hours=1):
return dict(previous)
except (KeyError, TypeError, ValueError):
pass
if previous:
stable_fields = (
"channel_type",
"instance_id",
"receiver",
"name",
"is_group",
"session_id",
)
unchanged = all(previous.get(field) == entry[field] for field in stable_fields)
if unchanged:
try:
last_seen = datetime.fromisoformat(previous["last_seen_at"])
if datetime.now(timezone.utc) - last_seen < timedelta(hours=1):
return dict(previous)
except (KeyError, TypeError, ValueError):
pass
recipients[key] = entry
self._save_unlocked(recipients)
return dict(entry)
def get(self, channel_type: str, receiver: str) -> Optional[dict]:
def get(self, instance_id: str, receiver: str) -> Optional[dict]:
"""Resolve one recipient by the instance that saw them.
``instance_id`` may be a legacy channel type, which is the id a
single-instance channel runs under, so old lookups keep working.
"""
instance_id = str(instance_id or "").strip()
with self._lock:
entry = self._load_unlocked().get(self._key(channel_type, receiver))
return dict(entry) if entry else None
entry = self._load_unlocked().get(self._key(instance_id, receiver))
return self._normalize(entry) if entry else None
def list(self) -> List[dict]:
with self._lock:
entries = [dict(item) for item in self._load_unlocked().values()]
return sorted(entries, key=lambda item: (item["channel_type"], item["name"], item["receiver"]))
entries = [self._normalize(item) for item in self._load_unlocked().values()]
return sorted(
entries,
key=lambda item: (item["channel_type"], item["name"], item["receiver"]),
)
+67 -6
View File
@@ -32,7 +32,7 @@ class SchedulerTool(BaseTool):
"- cron: cron表达式,如'0 8 * * *'表示每天8点\n\n"
"注意:'X秒后'用once+相对时间,'每X秒'用interval\n"
"For Web cross-channel delivery, call action='list_recipients' first, "
"then create a fixed-message task using the returned channel_type and receiver."
"then create a task (fixed message or ai_task) using the returned channel_type and receiver."
)
params: dict = {
"type": "object",
@@ -77,7 +77,11 @@ class SchedulerTool(BaseTool):
},
"channel_type": {
"type": "string",
"description": "Target channel for a Web-created fixed-message task. Use list_recipients first."
"description": "Target channel type for a Web-created cross-channel task. Use list_recipients first."
},
"instance_id": {
"type": "string",
"description": "The specific channel instance the recipient belongs to, as returned by list_recipients. Required when a channel type runs more than one instance; the same receiver on two instances is two different targets. Omit for a single-instance channel."
},
"receiver": {
"type": "string",
@@ -179,17 +183,19 @@ class SchedulerTool(BaseTool):
target_channel = kwargs.get("channel_type")
target_receiver = kwargs.get("receiver")
# A channel type can run several instances; delivery must go back through
# the exact one that saw the contact. When the caller does not name an
# instance (legacy single-instance channel), it equals the channel type.
target_instance = kwargs.get("instance_id") or target_channel
target = None
if target_channel or target_receiver:
if context.get("channel_type") != "web":
return "Error: cross-channel recipients can only be selected from the Web console"
if not target_channel or not target_receiver:
return "Error: channel_type and receiver must be provided together"
if ai_task:
return "Error: cross-channel delivery currently supports fixed messages only"
if not self.recipient_store:
return "Error: trusted recipient directory is unavailable"
target = self.recipient_store.get(target_channel, target_receiver)
target = self.recipient_store.get(target_instance, target_receiver)
if not target:
return "Error: target is not in the trusted recipient directory; use list_recipients"
@@ -204,6 +210,12 @@ class SchedulerTool(BaseTool):
receiver_name = target["name"] if target else self._get_receiver_name(context)
is_group = target["is_group"] if target else context.get("isgroup", False)
channel_type = target["channel_type"] if target else self.config.get("channel_type", "unknown")
# The instance to deliver back through: the target's own for a
# cross-channel task, else this conversation's. Defaults to the channel
# type so a legacy single-instance channel keeps resolving as before.
instance_id = (
target.get("instance_id") if target else context.get("instance_id")
) or channel_type
# Build action based on message or ai_task
if message:
@@ -214,6 +226,7 @@ class SchedulerTool(BaseTool):
"receiver_name": receiver_name,
"is_group": is_group,
"channel_type": channel_type,
"instance_id": instance_id,
"notify_session_id": notify_session_id,
}
else: # ai_task
@@ -224,6 +237,7 @@ class SchedulerTool(BaseTool):
"receiver_name": receiver_name,
"is_group": is_group,
"channel_type": channel_type,
"instance_id": instance_id,
"notify_session_id": notify_session_id,
}
# silent only applies to ai_task; fixed messages always deliver
@@ -235,9 +249,15 @@ class SchedulerTool(BaseTool):
if msg and hasattr(msg, 'sender_staff_id') and not context.get("isgroup", False):
action["dingtalk_sender_staff_id"] = msg.sender_staff_id
# The Agent this task runs as. Stored on the task (not implied by which
# file it lives in) so the global scheduler can scope execution to it and
# so re-binding a channel instance to a different Agent never moves data.
owner_agent_id = self._resolve_owner_agent_id(context, target)
task_data = {
"id": task_id,
"name": name,
"agent_id": owner_agent_id,
"enabled": True,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
@@ -309,9 +329,10 @@ class SchedulerTool(BaseTool):
lines = ["Trusted scheduler recipients:"]
for item in recipients:
kind = "group" if item["is_group"] else "user"
instance_id = item.get("instance_id") or item["channel_type"]
lines.append(
f"- {item['name']} ({kind}): channel_type={item['channel_type']}, "
f"receiver={item['receiver']}"
f"instance_id={instance_id}, receiver={item['receiver']}"
)
return "\n".join(lines)
@@ -517,3 +538,43 @@ class SchedulerTool(BaseTool):
except Exception:
pass
return "未知"
def _resolve_owner_agent_id(self, context: Context, target: Optional[dict]) -> str:
"""Which Agent the new task runs as.
For a cross-channel task the owner is the Agent the target's channel
instance is bound to (delivery and identity should match how an inbound
message from that instance would route). For a normal conversation task
it is simply the Agent handling this conversation. Falls back to the
ambient runtime identity, then the default Agent.
"""
# Cross-channel: derive from the target instance's binding.
if target:
iid = (target.get("instance_id") or "").strip()
if iid:
try:
from channel.channel_instances import get_instance
from config import conf as _conf
inst = get_instance(_conf(), iid)
if inst and getattr(inst, "agent_id", ""):
return inst.agent_id
except Exception:
pass
# Conversation task: the Agent on the context, else ambient identity.
agent_id = (context.get("agent_id") if context else "") or ""
if not agent_id:
try:
from common.runtime_identity import current_identity
agent_id = current_identity().agent_id or ""
except Exception:
agent_id = ""
if agent_id:
return agent_id
try:
from agent.registry import get_agent_registry
return get_agent_registry().default_agent_id
except Exception:
return ""
+23 -4
View File
@@ -178,21 +178,40 @@ class TaskStore:
tasks = self.load_tasks()
return tasks.get(task_id)
def list_tasks(self, enabled_only: bool = False) -> List[dict]:
def list_tasks(self, enabled_only: bool = False, agent_id: str = None) -> List[dict]:
"""
List all tasks
Args:
enabled_only: If True, only return enabled tasks
agent_id: If given, only return tasks owned by this Agent. Ownership
is the task's *effective* owner: for an IM task that is the
delivery instance's current binding (so re-binding a channel
re-buckets its tasks with no data change), else the stored
``agent_id``, else the default Agent. This keeps the per-Agent
list identical to what actually runs.
Returns:
List of task dictionaries
"""
tasks = self.load_tasks()
task_list = list(tasks.values())
if enabled_only:
task_list = [t for t in task_list if t.get("enabled", True)]
if agent_id:
from agent.tools.scheduler.integration import effective_task_agent_id
default_id = ""
try:
from agent.registry import get_agent_registry
default_id = get_agent_registry().default_agent_id
except Exception:
pass
task_list = [
t for t in task_list
if (effective_task_agent_id(t) or default_id) == agent_id
]
# Sort by enabled status (enabled first), then by next_run_at
def sort_key(t):
+30 -8
View File
@@ -15,7 +15,12 @@ from plugins import *
import threading
_channel_mgr = None
# The manager lives in common.channel_registry, not here: app.py runs as
# ``__main__`` (``python app.py``), so a module-level global here would be
# invisible to any code that does ``from app import ...`` (that import builds a
# second, separate ``app`` module). The registry is a single shared cell every
# caller sees — see common/channel_registry.py and issue #3120.
from common.channel_registry import get_channel_manager, set_channel_manager
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
# loaded in a background thread (so command plugins like cow_cli/godcmd work
@@ -23,10 +28,6 @@ _channel_mgr = None
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
def get_channel_manager():
return _channel_mgr
def _parse_channel_type(raw) -> list:
"""
Parse channel_type config value into a list of channel names.
@@ -149,6 +150,27 @@ class ChannelManager:
def get_channel(self, channel_name: str):
return self._channels.get(channel_name)
def find_channels_by_type(self, channel_type: str) -> list:
"""Every running channel whose type matches, keyed by registry name.
Instances register under their ``instance_id`` (e.g. ``weixin-c696...``),
so a caller that only knows the bare ``channel_type`` (an old scheduled
task whose action never stored an instance_id) can't hit ``get_channel``
directly. This lets it recover the live instance(s) of that type instead
of falling back to a never-started bare singleton. Returns ``[(name,
channel), ...]`` in registry order.
"""
ctype = (channel_type or "").strip()
with self._lock:
items = list(self._channels.items())
out = []
for name, ch in items:
if ch is None:
continue
if getattr(ch, "channel_type", "") == ctype:
out.append((name, ch))
return out
@staticmethod
def _normalize_entry(entry):
"""Accept both a legacy channel-type string and a ChannelInstance.
@@ -705,7 +727,6 @@ def _scaffold_subagent_assets():
def run():
global _channel_mgr
try:
# Before any TLS connection: a packaged build has no OpenSSL CA store.
bundle = ensure_ca_bundle()
@@ -759,8 +780,9 @@ def run():
logger.info(f"[App] Starting channels: {channel_names}")
_channel_mgr = ChannelManager()
_channel_mgr.start(channel_names, first_start=True)
channel_mgr = ChannelManager()
set_channel_manager(channel_mgr)
channel_mgr.start(channel_names, first_start=True)
while True:
time.sleep(1)
+59 -4
View File
@@ -92,6 +92,20 @@ CREDENTIAL_KEYS: Dict[str, tuple] = {
),
}
# Short human labels used only to seed a new instance's default name (e.g.
# "微信 2"). Purely cosmetic and easily overridden by the user, so an unknown
# type simply falls back to its raw channel_type string.
_CHANNEL_TYPE_LABELS: Dict[str, str] = {
const.FEISHU: "飞书",
const.DINGTALK: "钉钉",
const.WECOM_BOT: "企微机器人",
const.WEIXIN: "微信",
const.QQ: "QQ",
const.TELEGRAM: "Telegram",
const.SLACK: "Slack",
const.DISCORD: "Discord",
}
# Channel types that actually support running more than one instance today.
# Others may appear in channel_instances but will run as a single instance
# (their @singleton is not yet bypassed); we log and fall back gracefully.
@@ -114,6 +128,10 @@ class ChannelInstance:
instance_id: str
channel_type: str
agent_id: str = ""
#: Human-friendly label the user can edit (e.g. "运营微信"). Optional: when
#: empty the UI falls back to a credential-derived name or the instance id, so
#: an install that never sets one is unaffected.
name: str = ""
credentials: Dict[str, Any] = field(default_factory=dict)
#: Other Agents sharing this channel's conversations. ``agent_id`` is the
#: owner/leader that receives every inbound message; ``members`` are the
@@ -175,6 +193,7 @@ def _explicit_instances(raw_list: list) -> List[ChannelInstance]:
seen_ids.add(instance_id)
agent_id = str(raw.get("agent_id") or "").strip()
name = str(raw.get("name") or "").strip()
raw_members = raw.get("members")
members: List[str] = []
@@ -206,6 +225,7 @@ def _explicit_instances(raw_list: list) -> List[ChannelInstance]:
instance_id=instance_id,
channel_type=channel_type,
agent_id=agent_id,
name=name,
credentials=creds,
members=members,
legacy=False,
@@ -295,6 +315,7 @@ def bootstrap_legacy_instances(
"instance_id": ctype,
"channel_type": ctype,
"agent_id": default_id,
"name": _CHANNEL_TYPE_LABELS.get(ctype, ctype),
"credentials": creds,
}
)
@@ -360,6 +381,23 @@ def _clean_members(members, owner_id: str) -> list:
return out
def default_instance_name(channel_type: str, records: Iterable[Mapping[str, Any]]) -> str:
"""A friendly default label for a freshly created instance of *channel_type*.
Shape is "<type label>" for the first instance of a type and "<type label> N"
for later ones, so a second WeChat bot reads as "微信 2" rather than a random
id. Only used to seed the field; the user can rename it afterwards.
"""
ctype = _normalize_type((channel_type or "").strip())
label = _CHANNEL_TYPE_LABELS.get(ctype, ctype)
same_type = sum(
1
for r in records
if _normalize_type(str(r.get("channel_type") or "").strip()) == ctype
)
return label if same_type == 0 else f"{label} {same_type + 1}"
def upsert_instance(
settings: Mapping[str, Any],
channel_type: str,
@@ -367,14 +405,17 @@ def upsert_instance(
agent_id: Optional[str] = None,
credentials: Optional[Mapping[str, Any]] = None,
members: Optional[list] = None,
name: Optional[str] = None,
) -> ChannelInstance:
"""Create or update one channel instance record and persist it.
Matching is by ``instance_id``. When it is empty a new stable id is
generated. ``agent_id``, ``credentials`` and ``members`` are merged onto any
existing record so a partial update (e.g. credentials only) does not drop
the binding or the team. Pass ``members=None`` to leave the team untouched,
or ``members=[]`` to clear it. Returns the resulting :class:`ChannelInstance`.
generated. ``agent_id``, ``credentials``, ``members`` and ``name`` are merged
onto any existing record so a partial update (e.g. credentials only) does not
drop the binding, the team or the label. Pass ``members=None`` to leave the
team untouched, or ``members=[]`` to clear it. Pass ``name=None`` to leave the
label untouched; a brand-new record with no name is seeded with a friendly
default. Returns the resulting :class:`ChannelInstance`.
"""
from agent import team
@@ -395,6 +436,12 @@ def upsert_instance(
record["channel_type"] = ctype
if agent_id is not None:
record["agent_id"] = str(agent_id).strip()
if name is not None:
new_name = str(name).strip()
if new_name:
record["name"] = new_name
else:
record.pop("name", None)
if incoming_creds:
merged = dict(record.get("credentials") or {})
merged.update(incoming_creds)
@@ -410,10 +457,17 @@ def upsert_instance(
break
if not updated:
# Seed a friendly default label for a new instance so the console has
# something readable to show before the user renames it. Computed against
# the records *before* this one is appended, so the first is unnumbered.
seeded_name = str(name).strip() if name is not None else ""
if not seeded_name:
seeded_name = default_instance_name(ctype, records)
result_record = {
"instance_id": target_id,
"channel_type": ctype,
"agent_id": str(agent_id).strip() if agent_id is not None else "",
"name": seeded_name,
"credentials": incoming_creds,
}
cleaned = _clean_members(members, result_record["agent_id"])
@@ -429,6 +483,7 @@ def upsert_instance(
instance_id=target_id,
channel_type=ctype,
agent_id=str(result_record.get("agent_id") or ""),
name=str(result_record.get("name") or ""),
credentials=dict(result_record.get("credentials") or {}),
members=list(result_record.get("members") or []),
legacy=False,
+1 -1
View File
@@ -569,7 +569,7 @@ class FeiShuChanel(ChatChannel):
from agent.tools.scheduler.task_store import TaskStore
return TaskStore(str(state_dir.scheduler_file()))
return TaskStore(str(state_dir.scheduler_file_global()))
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
+26
View File
@@ -0,0 +1,26 @@
"""Process-wide handle to the running ChannelManager.
The entry module is launched as ``python app.py``, so it lives in
``sys.modules['__main__']``. Any later ``import app`` then builds a *second*,
distinct module object whose module-level globals (including ``_channel_mgr``)
are separate from the ones ``run()`` populated. Code that reached for the live
manager via ``from app import get_channel_manager`` therefore saw ``None`` and
silently fell back to a bare, never-started channel singleton — the scheduler
delivery bug behind issue #3120.
Holding the manager here instead keeps a single shared cell: ``common`` is
always imported as ``common.channel_registry`` (never as ``__main__``), so every
caller — the entry module and the scheduler alike — reads and writes the same
value.
"""
_channel_manager = None
def set_channel_manager(manager) -> None:
global _channel_manager
_channel_manager = manager
def get_channel_manager():
return _channel_manager
+36 -7
View File
@@ -15,8 +15,10 @@ State falls into three kinds, and which kind a path is decided here:
drift; what actually differs between Agents is which ones are switched on,
not which ones exist. An Agent that genuinely needs its own copy creates the
directory and wins by presence.
- **Per Agent** (``state_root``): persona, sessions, scheduled tasks, scratch.
What makes this Agent a different one from that Agent.
- **Per Agent** (``state_root``): persona, sessions, scratch.
What makes this Agent a different one from that Agent. Scheduled tasks are
shared (each task carries its own ``agent_id``) so re-binding a channel
instance never has to move a file.
- **Per end user** (``user_root``): profile, preferences, memory, task records.
"Wang likes email over phone calls" is a fact about Wang, not about one
Agent, so it sits beside the Agents rather than under one of them.
@@ -170,6 +172,38 @@ def env_file(identity=None, base=None) -> Path:
return _shared_or_own(identity, base, ".env")
def scheduler_recipients_file(base=None) -> Path:
"""The one directory of people/groups observed on inbound IM channels.
Shared, not per Agent: a contact is a fact about the person, not about which
Agent happened to talk to them, so one copy lets any Agent's console build a
scheduled task for anyone the instance has ever met. It lives beside the
global ``tasks.json`` under ``scheduler`` — the recipients a scheduled push
can target — rather than in a separate directory.
"""
if base is not None:
return Path(base) / "scheduler" / "recipients.json"
return shared_root() / "scheduler" / "recipients.json"
def scheduler_file_global(base=None) -> Path:
"""The one task store every Agent's scheduled tasks live in.
Shared, not per Agent, and each task carries its own ``agent_id`` (the Agent
it runs as) plus ``instance_id`` (the channel login it delivers through).
Keeping ownership *on the task* rather than *in the file path* is what lets a
channel instance be re-bound to a different Agent without moving any task
data: delivery resolves by ``instance_id`` and execution sets identity from
the task's ``agent_id``, neither of which depends on where the file sits.
Superseded the per-Agent ``scheduler_file`` (``<agent>/scheduler/tasks.json``);
a one-time boot migration folds those into this single store.
"""
if base is not None:
return Path(base) / "scheduler" / "tasks.json"
return shared_root() / "scheduler" / "tasks.json"
# --- Per Agent: what makes this Agent a different one -----------------------
@@ -177,11 +211,6 @@ def scheduler_file(identity=None, base=None) -> Path:
return _agent_base(identity, base) / "scheduler" / "tasks.json"
def scheduler_recipients_file(identity=None, base=None) -> Path:
"""Trusted recipients observed on this Agent's inbound channels."""
return _agent_base(identity, base) / "scheduler" / "recipients.json"
def tmp_dir(identity=None, ensure: bool = True, base=None) -> Path:
"""Transient downloads and synthesized media.
+2 -2
View File
@@ -424,9 +424,9 @@ class CowCliPlugin(Plugin):
task_store = get_task_store()
if task_store is None:
from agent.tools.scheduler.task_store import TaskStore
from common.state_dir import scheduler_file
from common.state_dir import scheduler_file_global
task_store = TaskStore(str(scheduler_file()))
task_store = TaskStore(str(scheduler_file_global()))
channel_type = ""
receiver = ""
+54
View File
@@ -172,6 +172,60 @@ def test_upsert_honors_provided_id(tmp_path):
assert inst.instance_id == "from-remote-123"
# ---------------------------------------------------------------------------
# instance name: a user-editable friendly label, seeded with a default and
# fully optional so legacy records without one keep working.
# ---------------------------------------------------------------------------
def test_new_instance_gets_default_name(tmp_path):
settings = {"agent_workspace": str(tmp_path)}
first = ci.upsert_instance(settings, channel_type="weixin", credentials={})
second = ci.upsert_instance(settings, channel_type="weixin", credentials={})
# First of a type is unnumbered; the second is suffixed so they read apart.
assert first.name == "微信"
assert second.name == "微信 2"
def test_upsert_can_set_and_clear_name(tmp_path):
settings = {"agent_workspace": str(tmp_path)}
inst = ci.upsert_instance(settings, channel_type="feishu",
credentials={"feishu_app_id": "A"})
renamed = ci.upsert_instance(settings, channel_type="feishu",
instance_id=inst.instance_id, name="运营飞书")
assert renamed.name == "运营飞书"
# Renaming must not disturb credentials or duplicate the record.
assert renamed.credentials["feishu_app_id"] == "A"
assert len(ci.read_raw_instances(settings)) == 1
# Empty name clears the label back to unset.
cleared = ci.upsert_instance(settings, channel_type="feishu",
instance_id=inst.instance_id, name="")
assert cleared.name == ""
def test_upsert_partial_update_keeps_name(tmp_path):
settings = {"agent_workspace": str(tmp_path)}
inst = ci.upsert_instance(settings, channel_type="feishu", name="客服飞书",
credentials={"feishu_app_id": "A"})
# A credentials-only update (name=None) must preserve the existing label.
updated = ci.upsert_instance(settings, channel_type="feishu",
instance_id=inst.instance_id,
credentials={"feishu_app_secret": "S"})
assert updated.name == "客服飞书"
def test_legacy_record_without_name_resolves(tmp_path):
# A record written before the name field existed must still load, with an
# empty name (the UI falls back to the id) and no error.
settings = _write_instances(
tmp_path,
[{"instance_id": "feishu-old", "channel_type": "feishu",
"credentials": {"feishu_app_id": "A"}}],
)
inst = ci.resolve_channel_instances(settings)[0]
assert inst.name == ""
assert inst.instance_id == "feishu-old"
def test_remove_instance(tmp_path):
settings = {"agent_workspace": str(tmp_path)}
inst = ci.upsert_instance(settings, channel_type="feishu",
+65 -17
View File
@@ -119,31 +119,79 @@ def test_memory_config_keeps_each_agent_index_under_its_workspace(
assert research_db == Path(research.workspace) / "memory/long-term/index.db"
def test_scheduler_stores_allow_same_task_id_per_agent(isolated_registry):
def test_scheduler_is_one_global_store_tagged_by_agent_id(isolated_registry):
"""Tasks for every Agent live in one file; ownership is a field, not a path.
Re-binding a channel instance therefore never has to move a task."""
class Bridge:
agent_registry = isolated_registry
bridge = Bridge()
for profile in isolated_registry.list(include_disabled=False):
assert init_scheduler(bridge, profile.workspace, profile.id)
assert init_scheduler(bridge)
primary_store = get_task_store(agent_id="primary")
research_store = get_task_store(agent_id="research")
primary_store.add_task(_task("daily", "Primary daily"))
research_store.add_task(_task("daily", "Research daily"))
store = get_task_store()
primary = _task("daily-p", "Primary daily")
primary["agent_id"] = "primary"
research = _task("daily-r", "Research daily")
research["agent_id"] = "research"
store.add_task(primary)
store.add_task(research)
assert primary_store is not research_store
assert primary_store.get_task("daily")["name"] == "Primary daily"
assert research_store.get_task("daily")["name"] == "Research daily"
assert Path(primary_store.store_path) == Path(
isolated_registry.get("primary").workspace
) / "scheduler/tasks.json"
assert Path(research_store.store_path) == Path(
isolated_registry.get("research").workspace
) / "scheduler/tasks.json"
assert get_scheduler_service(agent_id="primary") is not get_scheduler_service(
assert get_task_store(agent_id="primary") is store
assert get_task_store(agent_id="research") is store
assert get_scheduler_service(agent_id="primary") is get_scheduler_service(
agent_id="research"
)
assert [t["id"] for t in store.list_tasks(agent_id="primary")] == ["daily-p"]
assert [t["id"] for t in store.list_tasks(agent_id="research")] == ["daily-r"]
assert Path(store.store_path) == Path(
isolated_registry.get("primary").workspace
) / "scheduler/tasks.json"
def test_legacy_per_agent_task_files_fold_into_the_global_store(isolated_registry):
"""A one-time boot migration imports each Agent's old tasks.json, stamps
``agent_id``, and leaves the source renamed aside — not deleted, so a
rollback is possible. The default Agent's file *is* the global store, so
those tasks stay put and only get an owner stamp."""
import json
class Bridge:
agent_registry = isolated_registry
primary = isolated_registry.get("primary")
research = isolated_registry.get("research")
global_path = Path(primary.workspace) / "scheduler" / "tasks.json"
legacy_path = Path(research.workspace) / "scheduler" / "tasks.json"
global_path.parent.mkdir(parents=True, exist_ok=True)
legacy_path.parent.mkdir(parents=True, exist_ok=True)
global_path.write_text(
json.dumps({"version": 1, "tasks": {"old-p": _task("old-p", "Default leftover")}}),
encoding="utf-8",
)
legacy_path.write_text(
json.dumps({"version": 1, "tasks": {"old-r": _task("old-r", "Research leftover")}}),
encoding="utf-8",
)
assert init_scheduler(Bridge())
store = get_task_store()
assert store.get_task("old-p")["agent_id"] == "primary"
assert store.get_task("old-r")["agent_id"] == "research"
assert store.get_task("old-r")["name"] == "Research leftover"
assert not legacy_path.exists()
assert legacy_path.with_name("tasks.json.migrated").exists()
def test_list_tasks_treats_missing_agent_id_as_the_default(isolated_registry):
class Bridge:
agent_registry = isolated_registry
assert init_scheduler(Bridge())
store = get_task_store()
store.add_task(_task("orphan", "No owner field"))
assert [t["id"] for t in store.list_tasks(agent_id="primary")] == ["orphan"]
assert store.list_tasks(agent_id="research") == []
def test_each_agent_boots_only_its_own_mcp_servers(mcp_workspaces, monkeypatch):
+295
View File
@@ -0,0 +1,295 @@
"""Regression tests for scheduler channel resolution / readiness (issue #3120).
A weixin bot that runs as a channel_instance registers under its instance_id
(e.g. ``weixin-c696...``). A scheduled task whose action never stored an
instance_id must still resolve to that live instance instead of a bare,
never-started ``create_channel`` singleton, otherwise its token map is empty and
readiness defers the task forever.
"""
import sys
import types
import agent.tools.scheduler.integration as integration
class _FakeChannel:
def __init__(self, channel_type, instance_id="", tokens=None):
self.channel_type = channel_type
self.instance_id = instance_id
self._context_tokens = dict(tokens or {})
class _FakeManager:
def __init__(self, channels):
# channels: dict registry_name -> _FakeChannel
self._channels = dict(channels)
def get_channel(self, name):
return self._channels.get(name)
def find_channels_by_type(self, channel_type):
return [
(name, ch)
for name, ch in self._channels.items()
if getattr(ch, "channel_type", "") == channel_type
]
def _install_manager(monkeypatch, manager):
"""Make ``common.channel_registry.get_channel_manager`` return our fake."""
import common.channel_registry as registry
monkeypatch.setattr(registry, "get_channel_manager", lambda: manager)
class _FakeRecipientStore:
def __init__(self, entries):
self._entries = list(entries)
def list(self):
return list(self._entries)
def _install_directory(monkeypatch, entries):
monkeypatch.setattr(
integration,
"_get_shared_recipient_store",
lambda: _FakeRecipientStore(entries),
)
def test_resolves_live_instance_when_task_has_no_instance_id(monkeypatch):
live = _FakeChannel("weixin", "weixin-c6961093a5", tokens={"user@im": "tok"})
_install_manager(monkeypatch, _FakeManager({"weixin-c6961093a5": live}))
_install_directory(monkeypatch, [])
resolved = integration._resolve_delivery_channel("weixin", "", "user@im")
assert resolved is live
def test_ready_true_for_instance_run_weixin_without_instance_id(monkeypatch):
live = _FakeChannel("weixin", "weixin-c6961093a5", tokens={"user@im": "tok"})
_install_manager(monkeypatch, _FakeManager({"weixin-c6961093a5": live}))
_install_directory(monkeypatch, [])
assert integration._is_channel_ready("weixin", "user@im", instance_id="") is True
def test_multiple_instances_prefers_the_one_holding_the_receiver(monkeypatch):
other = _FakeChannel("weixin", "weixin-aaaa", tokens={"someone-else": "t"})
mine = _FakeChannel("weixin", "weixin-bbbb", tokens={"user@im": "tok"})
_install_manager(
monkeypatch,
_FakeManager({"weixin-aaaa": other, "weixin-bbbb": mine}),
)
_install_directory(monkeypatch, []) # no directory hit -> fall to token match
resolved = integration._resolve_delivery_channel("weixin", "", "user@im")
assert resolved is mine
def test_feishu_recovers_correct_instance_from_recipient_directory(monkeypatch):
"""Two Feishu apps; a task with no instance_id must resolve to the app that
actually owns the open_id, not the first one (which raises open_id cross
app). The recipient directory is the authoritative source of that mapping."""
app_a = _FakeChannel("feishu", "feishu-72734a6dbe")
app_b = _FakeChannel("feishu", "feishu-ec75a1f1f8")
_install_manager(
monkeypatch,
_FakeManager({"feishu-72734a6dbe": app_a, "feishu-ec75a1f1f8": app_b}),
)
_install_directory(
monkeypatch,
[
{
"channel_type": "feishu",
"instance_id": "feishu-ec75a1f1f8",
"receiver": "ou_a2a4af",
}
],
)
resolved = integration._resolve_delivery_channel("feishu", "", "ou_a2a4af")
assert resolved is app_b
def test_feishu_refuses_to_guess_between_instances_without_a_directory_hit(monkeypatch):
"""No instance_id and the receiver is unknown to the directory: we must not
blindly deliver through the first app (that is the cross-app bug). Falling
through to create_channel is fine — the point is we don't return app_a."""
app_a = _FakeChannel("feishu", "feishu-72734a6dbe")
app_b = _FakeChannel("feishu", "feishu-ec75a1f1f8")
_install_manager(
monkeypatch,
_FakeManager({"feishu-72734a6dbe": app_a, "feishu-ec75a1f1f8": app_b}),
)
_install_directory(monkeypatch, [])
bare = object()
fake_factory = types.ModuleType("channel.channel_factory")
fake_factory.create_channel = lambda *a, **k: bare
monkeypatch.setitem(sys.modules, "channel.channel_factory", fake_factory)
resolved = integration._resolve_delivery_channel("feishu", "", "ou_unknown")
assert resolved is not app_a
assert resolved is not app_b
assert resolved is bare
def test_single_feishu_instance_is_used_directly(monkeypatch):
"""Solo Feishu install: exactly one instance, so it is safe to auto-pick
even without a directory entry (legacy behaviour preserved)."""
only = _FakeChannel("feishu", "feishu-72734a6dbe")
_install_manager(monkeypatch, _FakeManager({"feishu-72734a6dbe": only}))
_install_directory(monkeypatch, [])
resolved = integration._resolve_delivery_channel("feishu", "", "ou_a2a4af")
assert resolved is only
def test_manager_is_shared_across_app_main_duplication(monkeypatch):
"""app.py runs as __main__; a later ``import app`` builds a second module.
The manager must be held in the shared registry so the scheduler (which
imports it, not __main__) sees the same object the entry module set — the
core of issue #3120."""
import common.channel_registry as registry
sentinel = object()
monkeypatch.setattr(registry, "_channel_manager", None)
try:
# Whatever the entry module set...
registry.set_channel_manager(sentinel)
# ...the scheduler-side import must observe.
assert registry.get_channel_manager() is sentinel
finally:
registry.set_channel_manager(None)
def test_exact_instance_id_still_wins(monkeypatch):
a = _FakeChannel("weixin", "weixin-aaaa", tokens={"user@im": "t"})
b = _FakeChannel("weixin", "weixin-bbbb", tokens={"user@im": "t"})
_install_manager(monkeypatch, _FakeManager({"weixin-aaaa": a, "weixin-bbbb": b}))
resolved = integration._resolve_delivery_channel("weixin", "weixin-bbbb", "user@im")
assert resolved is b
def test_bare_singleton_fallback_does_not_hard_block_readiness(monkeypatch):
"""No live instance of the type: the token check is inconclusive, so the
scheduler must allow the send attempt instead of deferring forever."""
_install_manager(monkeypatch, _FakeManager({}))
bare = _FakeChannel("weixin", "", tokens={})
monkeypatch.setattr(
integration, "_resolve_delivery_channel", lambda *a, **k: bare
)
assert integration._is_channel_ready("weixin", "user@im", instance_id="") is True
def test_live_instance_without_token_is_still_not_ready(monkeypatch):
"""A real, running instance that has never seen the receiver is genuinely
not ready — we should defer, not blast into a login that can't route it."""
live = _FakeChannel("weixin", "weixin-c6961093a5", tokens={})
_install_manager(monkeypatch, _FakeManager({"weixin-c6961093a5": live}))
_install_directory(monkeypatch, [])
assert integration._is_channel_ready("weixin", "user@im", instance_id="") is False
# ── Backward-compatibility: legacy tasks with no instance_id / no agent_id ──
#
# Pre-existing tasks (created before multi-instance) store neither instance_id
# nor a per-instance login. They MUST keep delivering exactly as before. These
# tests pin that contract so a future change can't quietly break old data.
def test_legacy_single_instance_registers_under_channel_type(monkeypatch):
"""A non-multi install runs its channel under the bare channel_type key
(``weixin``). A legacy task with no instance_id resolves to it via
``lookup = instance_id or channel_type`` — the original single-bot path."""
legacy = _FakeChannel("weixin", "weixin", tokens={"user@im": "tok"})
_install_manager(monkeypatch, _FakeManager({"weixin": legacy}))
_install_directory(monkeypatch, [])
resolved = integration._resolve_delivery_channel("weixin", "", "user@im")
assert resolved is legacy
assert integration._is_channel_ready("weixin", "user@im", instance_id="") is True
def test_no_manager_falls_back_to_create_channel_like_before(monkeypatch):
"""Before ChannelManager exists (or any failure reaching it), resolution
must fall back to ``create_channel(channel_type)`` — byte-for-byte the old
behaviour, so a solo install and old tasks are untouched."""
import common.channel_registry as registry
monkeypatch.setattr(registry, "get_channel_manager", lambda: None)
bare = object()
fake_factory = types.ModuleType("channel.channel_factory")
called = {}
def _create(channel_type, *a, **k):
called["channel_type"] = channel_type
return bare
fake_factory.create_channel = _create
monkeypatch.setitem(sys.modules, "channel.channel_factory", fake_factory)
resolved = integration._resolve_delivery_channel("feishu", "", "ou_x")
assert resolved is bare
assert called["channel_type"] == "feishu"
def test_legacy_feishu_task_without_instance_id_single_instance(monkeypatch):
"""The common legacy case: one Feishu app, an old task with no instance_id
and no directory entry. Auto-picking the sole running instance is safe and
keeps the task delivering."""
only = _FakeChannel("feishu", "feishu")
_install_manager(monkeypatch, _FakeManager({"feishu": only}))
_install_directory(monkeypatch, [])
resolved = integration._resolve_delivery_channel("feishu", "", "ou_f1cbd4")
assert resolved is only
def test_comma_joined_channel_type_is_normalized(monkeypatch):
"""config.json permits ``channel_type='web,feishu'``; a task copied from it
stores that whole string. Readiness/resolution must not choke on it — the
primary type is taken so the task still resolves instead of raising."""
assert integration._primary_channel_type("feishu,dingtalk") == "feishu"
assert integration._primary_channel_type("") == "unknown"
assert integration._primary_channel_type(None) == "unknown"
def test_unknown_channel_type_is_always_ready(monkeypatch):
"""A task with a missing/unknown channel_type must not be blocked by the
readiness probe (unknown -> True), preserving pre-check behaviour."""
assert integration._is_channel_ready("", "whoever") is True
assert integration._is_channel_ready("unknown", "whoever") is True
def test_directory_lookup_failure_does_not_break_resolution(monkeypatch):
"""If the recipient directory is unreadable, resolution must degrade
gracefully (fall through), never raise into the scheduler tick."""
only = _FakeChannel("feishu", "feishu")
_install_manager(monkeypatch, _FakeManager({"feishu": only}))
def _boom():
raise OSError("directory unavailable")
monkeypatch.setattr(integration, "_get_shared_recipient_store", _boom)
# Single instance -> still resolves despite the directory blowing up.
resolved = integration._resolve_delivery_channel("feishu", "", "ou_f1cbd4")
assert resolved is only
+127
View File
@@ -0,0 +1,127 @@
"""Ownership of a scheduled task follows its delivery channel instance.
A channel instance binds to exactly one Agent. An IM task delivers *through* an
instance, so the Agent it runs as (and the owner the console shows) is derived
live from that instance's current binding — never a value frozen on the task.
This is what lets an instance be re-bound to another Agent with zero task
migration, and keeps the edit form / task card honest the moment the delivery
instance is switched.
"""
import types
import agent.tools.scheduler.integration as integration
from agent.tools.scheduler.integration import effective_task_agent_id
class _FakeInstance:
def __init__(self, agent_id):
self.agent_id = agent_id
class _FakeRegistry:
"""Minimal registry: knows a fixed set of enabled agent ids + a default."""
def __init__(self, agents, default_id):
self._agents = set(agents)
self.default_agent_id = default_id
def get(self, agent_id, require_enabled=False):
if agent_id in self._agents:
return types.SimpleNamespace(id=agent_id)
raise KeyError(agent_id)
def _bind_instances(monkeypatch, mapping):
"""Point ``get_instance`` at a fixed instance_id -> agent_id map."""
import channel.channel_instances as ci
def _get_instance(_settings, instance_id):
agent_id = mapping.get(instance_id)
return _FakeInstance(agent_id) if agent_id is not None else None
monkeypatch.setattr(ci, "get_instance", _get_instance)
monkeypatch.setattr("config.conf", lambda: {}, raising=False)
def _im_task(instance_id, channel_type="feishu", stored_agent_id=""):
return {
"id": "t1",
"agent_id": stored_agent_id,
"action": {
"type": "agent_task",
"task_description": "x",
"channel_type": channel_type,
"instance_id": instance_id,
"receiver": "u1",
},
}
def test_im_task_owner_follows_the_instance_not_the_stored_value(monkeypatch):
# The instance is bound to "beta"; the task still carries a stale "alpha".
_bind_instances(monkeypatch, {"feishu-1": "beta"})
registry = _FakeRegistry({"alpha", "beta"}, default_id="alpha")
task = _im_task("feishu-1", stored_agent_id="alpha")
assert effective_task_agent_id(task, registry) == "beta"
def test_rebinding_the_instance_moves_the_task_with_no_rewrite(monkeypatch):
registry = _FakeRegistry({"alpha", "beta"}, default_id="alpha")
task = _im_task("feishu-1", stored_agent_id="alpha")
_bind_instances(monkeypatch, {"feishu-1": "alpha"})
assert effective_task_agent_id(task, registry) == "alpha"
# Re-bind the same instance; the task dict is untouched but ownership flips.
_bind_instances(monkeypatch, {"feishu-1": "beta"})
assert effective_task_agent_id(task, registry) == "beta"
assert task["agent_id"] == "alpha" # storage never rewritten
def test_unbound_instance_falls_back_to_the_stored_owner(monkeypatch):
# Instance exists but has no explicit binding (agent_id empty) -> stored wins.
_bind_instances(monkeypatch, {"feishu-1": ""})
registry = _FakeRegistry({"alpha", "beta"}, default_id="alpha")
task = _im_task("feishu-1", stored_agent_id="beta")
assert effective_task_agent_id(task, registry) == "beta"
def test_web_task_keeps_its_stored_owner(monkeypatch):
_bind_instances(monkeypatch, {})
registry = _FakeRegistry({"alpha", "beta"}, default_id="alpha")
task = {
"id": "w1",
"agent_id": "beta",
"action": {"type": "send_message", "channel_type": "web", "receiver": "s1"},
}
assert effective_task_agent_id(task, registry) == "beta"
def test_execution_degrades_to_default_when_instance_binds_disabled_agent(monkeypatch):
# Instance binds "ghost", who is not an enabled Agent. Execution must not run
# as a phantom identity: validate=True degrades to the default.
_bind_instances(monkeypatch, {"feishu-1": "ghost"})
registry = _FakeRegistry({"alpha"}, default_id="alpha")
task = _im_task("feishu-1", stored_agent_id="alpha")
class _Bridge:
agent_registry = registry
assert integration._resolve_task_agent_id(_Bridge(), task) == "alpha"
def test_display_keeps_stored_owner_without_a_live_registry(monkeypatch):
# Display path (validate=False) tolerates a missing registry: the owner is a
# label, so a task keeps showing its stored agent_id in bare contexts.
_bind_instances(monkeypatch, {})
task = {
"id": "n1",
"agent_id": "research",
"action": {"type": "agent_task", "task_description": "x"},
}
assert effective_task_agent_id(task, registry=None) == "research"