Post scheduled-run reports to a Slack channel

Scheduled runs previously had no source channel and finished silently
in the dashboard. Allow an automation to post its final report to a
configured Slack channel as the bot, reusing existing Slack plumbing
instead of the deferred run-completion webhook.

Refs: #82
This commit is contained in:
amoussa1229 2026-06-30 20:25:33 +00:00
parent eb98ff4c30
commit 4a5f0bc6c2
10 changed files with 295 additions and 6 deletions

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -22,6 +23,7 @@ SCHEDULES_NAMESPACE: list[str] = ["agent_schedules"]
_AGENT_ASSISTANT_ID = "agent" _AGENT_ASSISTANT_ID = "agent"
_SCHEDULER_ASSISTANT_ID = "scheduler" _SCHEDULER_ASSISTANT_ID = "scheduler"
_CRON_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7)) _CRON_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
_SLACK_CHANNEL_ID_RE = re.compile(r"^[A-Z0-9]{6,}$")
class ScheduleCreateBody(BaseModel): class ScheduleCreateBody(BaseModel):
@ -31,12 +33,18 @@ class ScheduleCreateBody(BaseModel):
repo: str | None = None repo: str | None = None
model_id: str | None = None model_id: str | None = None
effort: str | None = None effort: str | None = None
slack_report_channel: str | None = Field(default=None, max_length=120)
@field_validator("schedule") @field_validator("schedule")
@classmethod @classmethod
def _valid_schedule(cls, value: str) -> str: def _valid_schedule(cls, value: str) -> str:
return normalize_cron_schedule(value) return normalize_cron_schedule(value)
@field_validator("slack_report_channel")
@classmethod
def _valid_slack_report_channel(cls, value: str | None) -> str | None:
return normalize_slack_channel_id(value)
class ScheduleUpdateBody(BaseModel): class ScheduleUpdateBody(BaseModel):
prompt: str | None = Field(default=None, min_length=1, max_length=20_000) prompt: str | None = Field(default=None, min_length=1, max_length=20_000)
@ -46,12 +54,18 @@ class ScheduleUpdateBody(BaseModel):
model_id: str | None = None model_id: str | None = None
effort: str | None = None effort: str | None = None
enabled: bool | None = None enabled: bool | None = None
slack_report_channel: str | None = Field(default=None, max_length=120)
@field_validator("schedule") @field_validator("schedule")
@classmethod @classmethod
def _valid_schedule(cls, value: str | None) -> str | None: def _valid_schedule(cls, value: str | None) -> str | None:
return normalize_cron_schedule(value) if value is not None else None return normalize_cron_schedule(value) if value is not None else None
@field_validator("slack_report_channel")
@classmethod
def _valid_slack_report_channel(cls, value: str | None) -> str | None:
return normalize_slack_channel_id(value)
def _client(): def _client():
return langgraph_client() return langgraph_client()
@ -109,6 +123,18 @@ def normalize_cron_schedule(raw: str) -> str:
return value return value
def normalize_slack_channel_id(raw: str | None) -> str | None:
"""Normalize a Slack channel ID; blank becomes None (no report channel)."""
if raw is None:
return None
value = raw.strip().lstrip("#")
if not value:
return None
if not _SLACK_CHANNEL_ID_RE.match(value):
raise ValueError("slack_report_channel must be a Slack channel ID (e.g. C0123ABCD)")
return value
def _derive_name(prompt: str) -> str: def _derive_name(prompt: str) -> str:
return prompt.strip().splitlines()[0][:80] or "Scheduled agent" return prompt.strip().splitlines()[0][:80] or "Scheduled agent"
@ -131,6 +157,7 @@ def _schedule_summary(record: dict[str, Any]) -> dict[str, Any]:
"repo": _repo_full_name(repo), "repo": _repo_full_name(repo),
"model": record.get("model"), "model": record.get("model"),
"effort": record.get("effort"), "effort": record.get("effort"),
"slackReportChannel": record.get("slack_report_channel"),
"enabled": bool(record.get("enabled")), "enabled": bool(record.get("enabled")),
"cronId": record.get("cron_id"), "cronId": record.get("cron_id"),
"lastThreadId": record.get("last_thread_id"), "lastThreadId": record.get("last_thread_id"),
@ -274,6 +301,7 @@ async def create_agent_schedule(
"repo": repo, "repo": repo,
"model": chosen_model or profile.get("default_model") or "Default", "model": chosen_model or profile.get("default_model") or "Default",
"effort": chosen_effort or profile.get("reasoning_effort"), "effort": chosen_effort or profile.get("reasoning_effort"),
"slack_report_channel": body.slack_report_channel,
"base_branch": profile.get("base_branch") or "main", "base_branch": profile.get("base_branch") or "main",
"branch_prefix": profile.get("branch_prefix"), "branch_prefix": profile.get("branch_prefix"),
"enabled": True, "enabled": True,
@ -322,6 +350,8 @@ async def update_agent_schedule(
patch["effort"] = effort patch["effort"] = effort
if body.enabled is not None: if body.enabled is not None:
patch["enabled"] = body.enabled patch["enabled"] = body.enabled
if "slack_report_channel" in body.model_fields_set:
patch["slack_report_channel"] = body.slack_report_channel
updated = {**existing, **patch} updated = {**existing, **patch}
schedule_changed = updated.get("schedule") != existing.get("schedule") schedule_changed = updated.get("schedule") != existing.get("schedule")
@ -390,6 +420,9 @@ def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
if model and effort: if model and effort:
configurable["agent_model_id"] = model configurable["agent_model_id"] = model
configurable["agent_effort"] = effort configurable["agent_effort"] = effort
report_channel = record.get("slack_report_channel")
if isinstance(report_channel, str) and report_channel.strip():
configurable["slack_thread"] = {"channel_id": report_channel.strip()}
return {"configurable": configurable, "metadata": _agent_version_metadata()} return {"configurable": configurable, "metadata": _agent_version_metadata()}

View file

@ -505,6 +505,23 @@ ALWAYS_CREATE_PR_SECTION = """---
The user's dashboard setting **Always Create PRs** is enabled. For code-change tasks, always open or update a draft pull request after committing and pushing the branch. This does not apply to questions, explanations, status checks, or other information-only requests where no files are changed.""" The user's dashboard setting **Always Create PRs** is enabled. For code-change tasks, always open or update a draft pull request after committing and pushing the branch. This does not apply to questions, explanations, status checks, or other information-only requests where no files are changed."""
def _render_scheduled_report_section(channel_id: str | None) -> str:
if not channel_id or not channel_id.strip():
return ""
return (
"---\n\n"
"### Scheduled Run Report\n\n"
"This is a scheduled (automated) run with a configured Slack report channel. "
"When you finish, post your final summary to that channel by calling "
"`slack_thread_reply` with your report — it posts a top-level message to the "
f"configured channel (`{channel_id.strip()}`) as the bot. Post exactly one "
"final report. If the run produced a pull request, include its link. If "
"`slack_thread_reply` reports a failure (for example `not_in_channel`, meaning "
"the bot is not a member of the channel), do not retry repeatedly — surface the "
"error in your final output instead."
)
def _render_repo_instructions_section(instructions: str | None) -> str: def _render_repo_instructions_section(instructions: str | None) -> str:
if not instructions or not instructions.strip(): if not instructions or not instructions.strip():
return "" return ""
@ -540,6 +557,7 @@ SYSTEM_PROMPT_TEMPLATE = (
+ EXTERNAL_UNTRUSTED_COMMENTS_SECTION + EXTERNAL_UNTRUSTED_COMMENTS_SECTION
+ COMMIT_PR_SECTION + COMMIT_PR_SECTION
+ "{pr_policy_override_section}" + "{pr_policy_override_section}"
+ "{scheduled_report_section}"
+ "{collaboration_section}" + "{collaboration_section}"
+ "{repo_instructions_section}" + "{repo_instructions_section}"
) )
@ -557,6 +575,7 @@ def construct_system_prompt(
repo_custom_instructions: str | None = None, repo_custom_instructions: str | None = None,
thread_url: str | None = None, thread_url: str | None = None,
corridor_enabled: bool = False, corridor_enabled: bool = False,
slack_report_channel: str | None = None,
) -> str: ) -> str:
default_prompt_section = _load_default_prompt() default_prompt_section = _load_default_prompt()
if default_repo and default_repo.get("owner") and default_repo.get("name"): if default_repo and default_repo.get("owner") and default_repo.get("name"):
@ -586,6 +605,7 @@ def construct_system_prompt(
default_prompt_section=default_prompt_section, default_prompt_section=default_prompt_section,
corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "", corridor_prompt_section=CORRIDOR_PROMPT if corridor_enabled else "",
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "", pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
scheduled_report_section=_render_scheduled_report_section(slack_report_channel),
collaboration_section=_render_collaboration_section(triggering_user_identity, thread_url), collaboration_section=_render_collaboration_section(triggering_user_identity, thread_url),
repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions), repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions),
commit_identity_name=commit_identity_name, commit_identity_name=commit_identity_name,

View file

@ -612,6 +612,17 @@ def _get_cached_sandbox_backend(thread_id: str) -> SandboxBackendProtocol:
return sandbox_backend return sandbox_backend
def _scheduled_report_channel(configurable: dict[str, Any]) -> str | None:
"""Slack channel a scheduled run should post its final report to, if configured."""
if (configurable or {}).get("source") != "schedule":
return None
slack_thread = (configurable or {}).get("slack_thread") or {}
channel_id = slack_thread.get("channel_id")
if isinstance(channel_id, str) and channel_id.strip():
return channel_id.strip()
return None
async def _observability_authorized(config: RunnableConfig, profile_login: str | None) -> bool: async def _observability_authorized(config: RunnableConfig, profile_login: str | None) -> bool:
"""Whether the triggering user may use the team observability tools. """Whether the triggering user may use the team observability tools.
@ -893,6 +904,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
repo_custom_instructions=repo_custom_instructions, repo_custom_instructions=repo_custom_instructions,
thread_url=dashboard_thread_url(thread_id), thread_url=dashboard_thread_url(thread_id),
corridor_enabled=bool(corridor_tools), corridor_enabled=bool(corridor_tools),
slack_report_channel=_scheduled_report_channel(configurable),
), ),
tools=[ tools=[
http_request, http_request,

View file

@ -9,6 +9,7 @@ from langgraph_sdk import get_client
from ..utils.slack import ( from ..utils.slack import (
convert_mentions_to_slack_format, convert_mentions_to_slack_format,
post_slack_thread_reply_with_ts, post_slack_thread_reply_with_ts,
post_slack_top_level_message_with_ts,
store_slack_message_run_mapping, store_slack_message_run_mapping,
) )
@ -51,10 +52,10 @@ def slack_thread_reply(
channel_id = slack_thread.get("channel_id") channel_id = slack_thread.get("channel_id")
thread_ts = slack_thread.get("thread_ts") thread_ts = slack_thread.get("thread_ts")
if not channel_id or not thread_ts: if not channel_id:
return { return {
"success": False, "success": False,
"error": "Missing slack_thread.channel_id or slack_thread.thread_ts in config", "error": "Missing slack_thread.channel_id in config",
} }
if not message.strip(): if not message.strip():
@ -152,11 +153,13 @@ def _slack_reply_failure_hint(slack_error: str | None) -> str:
async def _post_and_store_mapping( async def _post_and_store_mapping(
channel_id: str, channel_id: str,
thread_ts: str, thread_ts: str | None,
message: str, message: str,
*, *,
blocks: list[dict[str, Any]] | None = None, blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
if not thread_ts:
return await post_slack_top_level_message_with_ts(channel_id, message, blocks=blocks)
message_ts, slack_error = await post_slack_thread_reply_with_ts( message_ts, slack_error = await post_slack_thread_reply_with_ts(
channel_id, thread_ts, message, blocks=blocks channel_id, thread_ts, message, blocks=blocks
) )

View file

@ -350,6 +350,57 @@ async def post_slack_thread_reply(channel_id: str, thread_ts: str, text: str) ->
return message_ts is not None return message_ts is not None
async def post_slack_top_level_message_with_ts(
channel_id: str,
text: str,
*,
unfurl_links: bool = True,
unfurl_media: bool = True,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
"""Post a fresh top-level message to a channel and return its ts and error."""
if not SLACK_BOT_TOKEN:
return None, "missing_slack_bot_token"
payload: dict[str, Any] = {
"channel": channel_id,
"text": text,
"unfurl_links": unfurl_links,
"unfurl_media": unfurl_media,
}
if blocks:
payload["blocks"] = blocks
async with httpx.AsyncClient() as http_client:
try:
response = await http_client.post(
f"{SLACK_API_BASE_URL}/chat.postMessage",
headers=_slack_headers(),
json=payload,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
logger.warning("Slack chat.postMessage rate limited (retry-after=%s)", retry_after)
if retry_after:
return None, f"rate_limited: {retry_after}"
return None, "rate_limited"
response.raise_for_status()
data = response.json()
if not data.get("ok"):
error = data.get("error")
logger.warning("Slack chat.postMessage failed: %s", error)
if error == "ratelimited":
return None, "rate_limited"
return None, error
message_ts = data.get("ts")
if isinstance(message_ts, str) and message_ts:
return message_ts, None
return None, None
except httpx.HTTPError as exc:
logger.exception("Slack chat.postMessage request failed")
return None, f"http_error: {type(exc).__name__}"
async def post_slack_ephemeral_message( async def post_slack_ephemeral_message(
channel_id: str, user_id: str, text: str, thread_ts: str | None = None channel_id: str, user_id: str, text: str, thread_ts: str | None = None
) -> bool: ) -> bool:

View file

@ -136,6 +136,93 @@ def test_cron_validation_accepts_steps_ranges_and_lists() -> None:
assert body.schedule == "*/15 9-17 * * 1,3,5" assert body.schedule == "*/15 9-17 * * 1,3,5"
def test_slack_report_channel_normalizes_and_validates() -> None:
body = ScheduleCreateBody(
prompt="hello", schedule="0 9 * * 1", slack_report_channel=" #C0123ABCD "
)
assert body.slack_report_channel == "C0123ABCD"
blank = ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel=" ")
assert blank.slack_report_channel is None
with pytest.raises(ValidationError):
ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1", slack_report_channel="not a chan")
async def test_create_agent_schedule_persists_slack_report_channel(fake_client, auth) -> None: # noqa: ANN001, ARG001
body = ScheduleCreateBody(
name="Daily report",
prompt="Summarize merged PRs",
schedule="0 9 * * 1-5",
slack_report_channel="C0123ABCD",
)
result = await schedules.create_agent_schedule("alice", body, email="alice@example.com")
assert result["slackReportChannel"] == "C0123ABCD"
stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), result["id"])]
assert stored["slack_report_channel"] == "C0123ABCD"
async def test_update_agent_schedule_clears_slack_report_channel(fake_client) -> None: # noqa: ANN001
record = {
"id": "sched_1",
"name": "Daily",
"prompt": "Run daily",
"schedule": "0 9 * * *",
"repo": None,
"model": "Default",
"effort": None,
"slack_report_channel": "C0123ABCD",
"enabled": True,
"cron_id": "cron_old",
"created_by": "alice",
"user_email": "alice@example.com",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record)
result = await schedules.update_agent_schedule(
"sched_1",
"alice",
ScheduleUpdateBody(slack_report_channel=""),
email="alice@example.com",
)
assert result["slackReportChannel"] is None
def test_agent_run_config_seeds_slack_thread_channel() -> None:
record = {
"id": "sched_1",
"model": "Default",
"effort": None,
"created_by": "alice",
"user_email": "alice@example.com",
"slack_report_channel": "C0123ABCD",
}
config = schedules._agent_run_config(record, "thread_1")
assert config["configurable"]["slack_thread"] == {"channel_id": "C0123ABCD"}
def test_agent_run_config_omits_slack_thread_without_channel() -> None:
record = {
"id": "sched_1",
"model": "Default",
"effort": None,
"created_by": "alice",
"user_email": "alice@example.com",
"slack_report_channel": None,
}
config = schedules._agent_run_config(record, "thread_1")
assert "slack_thread" not in config["configurable"]
async def test_create_agent_schedule_registers_scheduler_cron(fake_client, auth) -> None: # noqa: ANN001, ARG001 async def test_create_agent_schedule_registers_scheduler_cron(fake_client, auth) -> None: # noqa: ANN001, ARG001
body = ScheduleCreateBody( body = ScheduleCreateBody(
name="Daily report", name="Daily report",

View file

@ -169,3 +169,65 @@ def test_slack_thread_reply_builds_option_blocks(monkeypatch: pytest.MonkeyPatch
assert actions["type"] == "actions" assert actions["type"] == "actions"
assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"] assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"]
assert actions["elements"][0]["action_id"] == "open_swe_option_select" assert actions["elements"][0]["action_id"] == "open_swe_option_select"
def _channel_only_config() -> dict[str, Any]:
return {"configurable": {"slack_thread": {"channel_id": "C9"}}}
def test_slack_thread_reply_requires_channel_id(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(slack_reply_tool, "get_config", lambda: {"configurable": {}})
result = slack_reply_tool.slack_thread_reply("hello")
assert result["success"] is False
assert result["error"] == "Missing slack_thread.channel_id in config"
def test_slack_thread_reply_posts_top_level_when_no_thread_ts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
captured.update({"channel_id": channel_id, "text": text, "blocks": blocks})
return "3.0", None
async def fail_thread_reply(*args: Any, **kwargs: Any) -> tuple[str | None, str | None]:
raise AssertionError("should not post a thread reply without thread_ts")
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
monkeypatch.setattr(slack_reply_tool, "post_slack_thread_reply_with_ts", fail_thread_reply)
result = slack_reply_tool.slack_thread_reply("Scheduled report")
assert result == {"success": True}
assert captured["channel_id"] == "C9"
assert captured["text"] == "Scheduled report"
def test_slack_thread_reply_top_level_surfaces_not_in_channel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_top_level(
channel_id: str,
text: str,
*,
blocks: list[dict[str, Any]] | None = None,
) -> tuple[str | None, str | None]:
return None, "not_in_channel"
monkeypatch.setattr(slack_reply_tool, "get_config", _channel_only_config)
monkeypatch.setattr(slack_reply_tool, "post_slack_top_level_message_with_ts", fake_top_level)
result = slack_reply_tool.slack_thread_reply("Scheduled report")
assert result["success"] is False
assert result["slack_error"] == "not_in_channel"
assert "do not retry" in result["hint"]

View file

@ -44,9 +44,7 @@ function scheduleToSelection(
(model) => (model) =>
model.id === schedule.model && model.efforts.includes(schedule.effort!) model.id === schedule.model && model.efforts.includes(schedule.effort!)
) )
return supported return supported ? { modelId: schedule.model, effort: schedule.effort } : null
? { modelId: schedule.model, effort: schedule.effort }
: null
} }
export function AutomationEditor({ export function AutomationEditor({
@ -73,6 +71,9 @@ export function AutomationEditor({
) )
const [repo, setRepo] = useState<string | null>(schedule?.repo ?? null) const [repo, setRepo] = useState<string | null>(schedule?.repo ?? null)
const [enabled, setEnabled] = useState(schedule?.enabled ?? true) const [enabled, setEnabled] = useState(schedule?.enabled ?? true)
const [slackReportChannel, setSlackReportChannel] = useState(
schedule?.slackReportChannel ?? ""
)
// undefined = untouched (derive from the schedule / default as models load). // undefined = untouched (derive from the schedule / default as models load).
const [selectionOverride, setSelectionOverride] = useState< const [selectionOverride, setSelectionOverride] = useState<
ModelSelection | null | undefined ModelSelection | null | undefined
@ -115,6 +116,7 @@ export function AutomationEditor({
repo, repo,
model_id: modelId, model_id: modelId,
effort, effort,
slack_report_channel: slackReportChannel.trim() || null,
}, },
{ onSuccess: () => navigate({ to: "/agents/automations" }) } { onSuccess: () => navigate({ to: "/agents/automations" }) }
) )
@ -132,6 +134,7 @@ export function AutomationEditor({
model_id: modelId, model_id: modelId,
effort, effort,
enabled, enabled,
slack_report_channel: slackReportChannel.trim() || null,
}, },
}, },
{ onSuccess: () => navigate({ to: "/agents/automations" }) } { onSuccess: () => navigate({ to: "/agents/automations" }) }
@ -264,6 +267,21 @@ export function AutomationEditor({
</div> </div>
</div> </div>
<SectionLabel>Report to Slack channel</SectionLabel>
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] p-3">
<input
value={slackReportChannel}
onChange={(e) => setSlackReportChannel(e.target.value)}
placeholder="Channel ID (e.g. C0123ABCD)"
className="w-full bg-transparent font-mono text-sm text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]"
/>
<p className="mt-2 text-xs text-[var(--ui-text-dim)]">
When set, each run posts its final summary to this channel as the
Open SWE bot. The bot must be a member of the channel. Leave blank
for no Slack report.
</p>
</div>
{errorMessage && ( {errorMessage && (
<p className="mt-4 text-xs text-[var(--ui-danger)]">{errorMessage}</p> <p className="mt-4 text-xs text-[var(--ui-danger)]">{errorMessage}</p>
)} )}

View file

@ -27,6 +27,7 @@ export interface ScheduleCreateRequest {
repo?: string | null repo?: string | null
model_id?: string | null model_id?: string | null
effort?: string | null effort?: string | null
slack_report_channel?: string | null
} }
export interface ScheduleUpdateRequest { export interface ScheduleUpdateRequest {
@ -37,6 +38,7 @@ export interface ScheduleUpdateRequest {
model_id?: string | null model_id?: string | null
effort?: string | null effort?: string | null
enabled?: boolean | null enabled?: boolean | null
slack_report_channel?: string | null
} }
export interface ThreadPrDiffFile { export interface ThreadPrDiffFile {

View file

@ -161,6 +161,7 @@ export interface AgentSchedule {
repo: string | null repo: string | null
model: string model: string
effort?: string | null effort?: string | null
slackReportChannel?: string | null
enabled: boolean enabled: boolean
cronId?: string | null cronId?: string | null
lastThreadId?: string | null lastThreadId?: string | null