diff --git a/bwh_hive/bwh_hive/agent_api.py b/bwh_hive/bwh_hive/agent_api.py
new file mode 100644
index 0000000..82c7dbc
--- /dev/null
+++ b/bwh_hive/bwh_hive/agent_api.py
@@ -0,0 +1,182 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Box → Hive callback API (specs/v2 §5.1).
+
+Endpoints the in-VM control plane calls to report agent lifecycle state back to a Hive
+Task. Auth is the shared "Agent" bot service key (00-architecture.md §2.2): every method
+asserts (a) the calling session is the Agent bot (by role), and (b) the target task is
+currently assigned to that bot — so a leaked key cannot drive arbitrary tasks.
+
+Status changes route through `orchestrator.service.set_agent_status` so transition
+validation and side effects stay centralized.
+
+All methods are type-annotated — Hive enforces require_type_annotated_api_methods.
+"""
+
+import json
+
+import frappe
+
+from bwh_hive.bwh_hive.notifications.dispatcher import notify
+from bwh_hive.bwh_hive.notifications.events import EventType, NotificationEvent
+from bwh_hive.bwh_hive.orchestrator import service
+
+AGENT_BOT_ROLE = "Agent Bot"
+
+
+def _assert_agent_caller() -> None:
+ """Reject callers that are not the Agent bot user (identified by role)."""
+ if AGENT_BOT_ROLE not in frappe.get_roles(frappe.session.user):
+ frappe.throw("Only the Agent bot may call agent callbacks.", frappe.PermissionError)
+
+
+def _assert_task_assigned(task: str) -> None:
+ """Reject writes to a task the calling Agent bot is not assigned to."""
+ if not frappe.db.exists("Hive Task", task):
+ frappe.throw(f"Hive Task {task} not found", frappe.DoesNotExistError)
+ assignees = json.loads(frappe.db.get_value("Hive Task", task, "_assign") or "[]")
+ if frappe.session.user not in assignees:
+ frappe.throw("This task is not assigned to the Agent bot.", frappe.PermissionError)
+
+
+def _guard(task: str) -> None:
+ _assert_agent_caller()
+ _assert_task_assigned(task)
+
+
+@frappe.whitelist(methods=["POST"])
+def report_agent_status(task: str, status: str, message: str | None = None) -> dict:
+ """Set a Hive Task's agent_status (box actor) and optionally append a comment."""
+ _guard(task)
+ prev = frappe.db.get_value("Hive Task", task, "agent_status")
+ service.set_agent_status(task, status, actor="box", message=message)
+ if status == "Provisioning" and prev != "Provisioning":
+ notify(NotificationEvent.from_task(EventType.PROVISIONING, task))
+ return {"ok": True, "agent_status": status}
+
+
+@frappe.whitelist(methods=["POST"])
+def set_spec_ready(
+ task: str,
+ code_url: str | None = None,
+ site_url: str | None = None,
+ spec_path: str | None = None,
+ branch: str | None = None,
+) -> dict:
+ """Record spec coordinates and advance the task to Spec Created."""
+ _guard(task)
+ doc = frappe.get_doc("Hive Task", task)
+ updates: dict = {}
+ if code_url:
+ updates["agent_code_url"] = code_url
+ if site_url:
+ updates["agent_site_url"] = site_url
+ if spec_path:
+ updates["agent_spec_path"] = spec_path
+ if branch:
+ updates["agent_branch"] = branch
+ if updates:
+ doc.db_set(updates)
+ prev = doc.agent_status
+ service.set_agent_status(doc, "Spec Created", actor="box", message="Spec ready for review.")
+ if prev != "Spec Created":
+ notify(NotificationEvent.from_task(EventType.SPEC_CREATED, task))
+ return {"ok": True, "agent_status": "Spec Created"}
+
+
+@frappe.whitelist(methods=["POST"])
+def set_pr_ready(task: str, pr_url: str, branch: str | None = None) -> dict:
+ """Record the PR link and advance the task to PR Ready."""
+ _guard(task)
+ doc = frappe.get_doc("Hive Task", task)
+ updates: dict = {"pr_link": pr_url}
+ if branch:
+ updates["agent_branch"] = branch
+ doc.db_set(updates)
+ prev = doc.agent_status
+ service.set_agent_status(doc, "PR Ready", actor="box", message=f"PR ready: {pr_url}")
+ if prev != "PR Ready":
+ notify(NotificationEvent.from_task(EventType.PR_READY, task))
+ return {"ok": True, "agent_status": "PR Ready"}
+
+
+@frappe.whitelist(methods=["POST"])
+def report_agent_error(task: str, error: str, phase: str | None = None) -> dict:
+ """Record a failure and move the task to Failed."""
+ _guard(task)
+ doc = frappe.get_doc("Hive Task", task)
+ doc.db_set("agent_last_error", error)
+ msg = f"Agent error ({phase}): {error}" if phase else f"Agent error: {error}"
+ prev = doc.agent_status
+ service.set_agent_status(doc, "Failed", actor="box", message=msg)
+ if prev != "Failed":
+ notify(NotificationEvent.from_task(EventType.FAILED, task, message=error, payload={"phase": phase}))
+ return {"ok": True, "agent_status": "Failed"}
+
+
+@frappe.whitelist(methods=["POST"])
+def append_agent_log(task: str, log: str, stream: str = "stdout") -> dict:
+ """Append a cheap, frequent log line as a task comment."""
+ _guard(task)
+ frappe.get_doc(
+ {
+ "doctype": "Hive Task Comment",
+ "task": task,
+ "content": f"[{stream}] {log}",
+ "posted_by": frappe.session.user,
+ }
+ ).insert(ignore_permissions=True)
+ # Nudge any open task sheet to refetch its comment feed live (specs/v2 09 realtime).
+ frappe.publish_realtime("hive_agent_log", {"task": task}, after_commit=True)
+ return {"ok": True}
+
+
+@frappe.whitelist(methods=["GET"])
+def get_task(task: str) -> dict:
+ """Return task context so the box can compose spec/implement prompts (read-only)."""
+ _guard(task)
+ doc = frappe.get_doc("Hive Task", task)
+ return {
+ "name": doc.name,
+ "title": doc.title,
+ "description": doc.description,
+ "project": doc.project,
+ "agent_status": doc.agent_status,
+ "agent_branch": doc.agent_branch,
+ "agent_spec_path": doc.agent_spec_path,
+ "github_issue_url": doc.github_issue_url,
+ "pr_link": doc.pr_link,
+ "prompts": resolve_prompts(doc.project),
+ }
+
+
+# Prompt fields configurable in Hive: a project override wins over the global default; a
+# blank/absent value falls through so the box uses its built-in (or SKILLS_REPO) template.
+_PROMPT_FIELDS = {
+ "spec": "agent_spec_prompt",
+ "implement": "agent_implement_prompt",
+ "changes": "agent_changes_prompt",
+}
+
+
+def resolve_prompts(project: str | None) -> dict:
+ """Resolve {spec,implement,changes} prompt templates: project override → global default.
+
+ Only non-empty values are returned; a missing key means "no Hive-configured prompt — box
+ falls back to its SKILLS_REPO file / shipped default" (00-architecture.md §5.1).
+ """
+ settings = frappe.get_cached_doc("Hive Settings")
+ project_doc = (
+ frappe.get_cached_doc("Hive Project", project)
+ if project and frappe.db.exists("Hive Project", project)
+ else None
+ )
+ resolved: dict = {}
+ for key, fieldname in _PROMPT_FIELDS.items():
+ value = (project_doc and (project_doc.get(fieldname) or "").strip()) or (
+ settings.get(fieldname) or ""
+ ).strip()
+ if value:
+ resolved[key] = value
+ return resolved
diff --git a/bwh_hive/bwh_hive/api.py b/bwh_hive/bwh_hive/api.py
index fa996d5..23bb12f 100644
--- a/bwh_hive/bwh_hive/api.py
+++ b/bwh_hive/bwh_hive/api.py
@@ -932,3 +932,86 @@ def resolve_project_slug(slug: str):
if not name:
frappe.throw("Project not found", frappe.DoesNotExistError)
return name
+
+
+# --------------------------------------------------------------------------- #
+# Agent surface — thin frontend-facing wrappers (specs/v2 09).
+#
+# The React app can't call doctype methods the way the desk does, so these wrap
+# the whitelisted Hive Task agent methods with a flat (task, ...) signature the
+# frontend calls via useFrappePostCall. Each underlying method re-asserts its
+# own guard (identity + write permission) — these wrappers add no trust boundary.
+# --------------------------------------------------------------------------- #
+@frappe.whitelist(methods=["POST"])
+def agent_approve_spec(task: str, note: str | None = None):
+ """Approve the agent's spec (wraps Hive Task.approve_spec)."""
+ return frappe.get_doc("Hive Task", task).approve_spec(note=note)
+
+
+@frappe.whitelist(methods=["POST"])
+def agent_request_changes(task: str, comment: str, path: str | None = None, line: str | None = None):
+ """Request another iteration, sending a single review comment as the §5.3 payload."""
+ body = (comment or "").strip()
+ if not body:
+ frappe.throw("Provide a review comment.")
+ entry: dict = {"author": frappe.session.user, "body": body}
+ if path:
+ entry["path"] = path
+ if line not in (None, ""):
+ entry["line"] = line
+ return frappe.get_doc("Hive Task", task).request_agent_changes([entry])
+
+
+@frappe.whitelist(methods=["POST"])
+def agent_mark_merged(task: str):
+ """Record that the PR was merged (wraps Hive Task.mark_agent_merged)."""
+ return frappe.get_doc("Hive Task", task).mark_agent_merged()
+
+
+@frappe.whitelist(methods=["POST"])
+def agent_retry(task: str):
+ """Re-provision a clean box for a Failed task (wraps Hive Task.retry_agent)."""
+ return frappe.get_doc("Hive Task", task).retry_agent()
+
+
+@frappe.whitelist(methods=["POST"])
+def agent_cancel(task: str):
+ """Cancel an in-flight agent task (wraps Hive Task.cancel_agent)."""
+ return frappe.get_doc("Hive Task", task).cancel_agent()
+
+
+@frappe.whitelist(methods=["POST"])
+def agent_teardown_now(task: str):
+ """Force-deprovision a Failed box (wraps Hive Task.teardown_agent_now)."""
+ return frappe.get_doc("Hive Task", task).teardown_agent_now()
+
+
+@frappe.whitelist(methods=["POST"])
+def agent_handoff(task: str):
+ """Start the agent loop from the product by assigning the task to the Agent bot.
+
+ Assigning to the Agent user is what triggers provisioning (Phase 1 _assign hook).
+ Re-asserts write permission — the same gate the desk assign flow enforces.
+ """
+ from frappe.desk.form.assign_to import add as assign_add
+
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ doc = frappe.get_doc("Hive Task", task)
+ doc.check_permission("write")
+ agent_user = service.get_agent_user()
+ if not agent_user:
+ frappe.throw("No Agent bot user is configured.")
+ assign_add({"doctype": "Hive Task", "name": task, "assign_to": [agent_user]})
+ return {"ok": True, "agent_user": agent_user}
+
+
+@frappe.whitelist()
+def resolved_prompts(project: str | None = None):
+ """Return the resolved {spec,implement,changes} prompts (project override → global).
+
+ Lets the per-project settings UI show which prompt the box would actually receive.
+ """
+ from bwh_hive.bwh_hive.agent_api import resolve_prompts
+
+ return resolve_prompts(project)
diff --git a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json
index 23d3ea4..a2194f0 100644
--- a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json
+++ b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json
@@ -21,7 +21,19 @@
"links_section",
"links",
"is_archived",
- "github_repo"
+ "github_repo",
+ "agent_section",
+ "agent_enabled",
+ "github_pat",
+ "agent_template_slug",
+ "target_app_name",
+ "target_app_repo",
+ "target_app_branch",
+ "skills_repo_override",
+ "agent_prompts_section",
+ "agent_spec_prompt",
+ "agent_implement_prompt",
+ "agent_changes_prompt"
],
"fields": [
{
@@ -113,6 +125,93 @@
"fieldtype": "Data",
"label": "GitHub Repository",
"description": "GitHub repository in owner/repo format (e.g. BuildWithHussain/hive)"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "agent_section",
+ "fieldtype": "Section Break",
+ "label": "Agent (v2)"
+ },
+ {
+ "default": "0",
+ "description": "Only agent-enabled projects can spawn agent boxes (specs/v2 §4.3).",
+ "fieldname": "agent_enabled",
+ "fieldtype": "Check",
+ "label": "Agent Enabled"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "Project PAT used inside the box for gh / git push (GIT_PAT).",
+ "fieldname": "github_pat",
+ "fieldtype": "Password",
+ "label": "GitHub PAT"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "BenchSpace template/golden to provision (defaults to Hive Settings).",
+ "fieldname": "agent_template_slug",
+ "fieldtype": "Data",
+ "label": "Agent Template Slug"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "Frappe app to install in the box (TARGET_APP_NAME).",
+ "fieldname": "target_app_name",
+ "fieldtype": "Data",
+ "label": "Target App Name"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "App source URL (TARGET_APP_REPO).",
+ "fieldname": "target_app_repo",
+ "fieldtype": "Data",
+ "label": "Target App Repo"
+ },
+ {
+ "default": "develop",
+ "depends_on": "agent_enabled",
+ "description": "App branch (TARGET_APP_BRANCH).",
+ "fieldname": "target_app_branch",
+ "fieldtype": "Data",
+ "label": "Target App Branch"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "Optional override for the global SKILLS_REPO.",
+ "fieldname": "skills_repo_override",
+ "fieldtype": "Data",
+ "label": "Skills Repo Override"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "agent_enabled",
+ "fieldname": "agent_prompts_section",
+ "fieldtype": "Section Break",
+ "label": "Agent Prompt Overrides"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "Overrides the global Spec Prompt for this project. Blank = inherit the global default.",
+ "fieldname": "agent_spec_prompt",
+ "fieldtype": "Code",
+ "label": "Spec Prompt Override",
+ "options": "Text"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "Overrides the global Implement Prompt for this project. Blank = inherit the global default.",
+ "fieldname": "agent_implement_prompt",
+ "fieldtype": "Code",
+ "label": "Implement Prompt Override",
+ "options": "Text"
+ },
+ {
+ "depends_on": "agent_enabled",
+ "description": "Overrides the global Changes Prompt for this project. Blank = inherit the global default.",
+ "fieldname": "agent_changes_prompt",
+ "fieldtype": "Code",
+ "label": "Changes Prompt Override",
+ "options": "Text"
}
],
"grid_page_length": 50,
diff --git a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py
index 6545883..94919d3 100644
--- a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py
+++ b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py
@@ -16,13 +16,24 @@ class HiveProject(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ agent_changes_prompt: DF.Code | None
+ agent_enabled: DF.Check
+ agent_implement_prompt: DF.Code | None
+ agent_spec_prompt: DF.Code | None
+ agent_template_slug: DF.Data | None
client: DF.Link | None
description: DF.TextEditor | None
+ github_pat: DF.Password | None
+ github_repo: DF.Data | None
is_archived: DF.Check
is_private: DF.Check
project_type: DF.Link | None
+ skills_repo_override: DF.Data | None
slug: DF.Data | None
status: DF.Literal["Open", "Completed", "On Hold"]
+ target_app_branch: DF.Data | None
+ target_app_name: DF.Data | None
+ target_app_repo: DF.Data | None
title: DF.Data
# end: auto-generated types
diff --git a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json
index 3140eb3..c8bcfe0 100644
--- a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json
+++ b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json
@@ -15,7 +15,32 @@
"github_app_private_key",
"github_access_token",
"github_username",
- "github_authorized_at"
+ "github_authorized_at",
+ "agent_section",
+ "agent_orchestration_enabled",
+ "benchspace_api_url",
+ "benchspace_api_key",
+ "benchspace_api_secret",
+ "agent_callback_api_key",
+ "agent_callback_api_secret",
+ "default_agent_template_slug",
+ "skills_repo",
+ "anthropic_api_key",
+ "agent_prompts_section",
+ "agent_spec_prompt",
+ "agent_implement_prompt",
+ "agent_changes_prompt",
+ "agent_lifecycle_section",
+ "max_concurrent_agent_boxes",
+ "provisioning_timeout_minutes",
+ "spec_timeout_minutes",
+ "implement_timeout_minutes",
+ "idle_teardown_hours",
+ "failed_teardown_grace_hours",
+ "telegram_section",
+ "notifications_enabled",
+ "telegram_bot_token",
+ "telegram_default_chat_id"
],
"fields": [
{
@@ -92,6 +117,158 @@
"hidden": 1,
"label": "GitHub Authorized At",
"read_only": 1
+ },
+ {
+ "fieldname": "agent_section",
+ "fieldtype": "Section Break",
+ "label": "Agent Orchestration (v2)"
+ },
+ {
+ "default": "0",
+ "description": "Master switch for v2 agent orchestration (specs/v2 §4.4).",
+ "fieldname": "agent_orchestration_enabled",
+ "fieldtype": "Check",
+ "label": "Agent Orchestration Enabled"
+ },
+ {
+ "description": "BenchSpace site base URL, e.g. https://boxes.buildwithhussain.com",
+ "fieldname": "benchspace_api_url",
+ "fieldtype": "Data",
+ "label": "BenchSpace API URL"
+ },
+ {
+ "fieldname": "benchspace_api_key",
+ "fieldtype": "Data",
+ "label": "BenchSpace API Key"
+ },
+ {
+ "fieldname": "benchspace_api_secret",
+ "fieldtype": "Password",
+ "label": "BenchSpace API Secret"
+ },
+ {
+ "description": "API key of the Hive Agent bot user; injected into every box (HIVE_API_KEY).",
+ "fieldname": "agent_callback_api_key",
+ "fieldtype": "Data",
+ "label": "Agent Callback API Key"
+ },
+ {
+ "fieldname": "agent_callback_api_secret",
+ "fieldtype": "Password",
+ "label": "Agent Callback API Secret"
+ },
+ {
+ "default": "agent-v16",
+ "fieldname": "default_agent_template_slug",
+ "fieldtype": "Data",
+ "label": "Default Agent Template Slug"
+ },
+ {
+ "description": "Global skills/CLAUDE.md/prompts repo (SKILLS_REPO).",
+ "fieldname": "skills_repo",
+ "fieldtype": "Data",
+ "label": "Skills Repo"
+ },
+ {
+ "description": "Injected into boxes as ANTHROPIC_API_KEY for Claude Code.",
+ "fieldname": "anthropic_api_key",
+ "fieldtype": "Password",
+ "label": "Anthropic API Key"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "agent_prompts_section",
+ "fieldtype": "Section Break",
+ "label": "Agent Prompts (global defaults)"
+ },
+ {
+ "description": "Global spec-generation prompt. Tokens: {title} {description} {spec_path} {project} {task_id}. Leave blank to use the box's built-in default. A project can override this.",
+ "fieldname": "agent_spec_prompt",
+ "fieldtype": "Code",
+ "label": "Spec Prompt",
+ "options": "Text"
+ },
+ {
+ "description": "Global implementation prompt. Tokens: {title} {description} {spec_path} {branch} {project} {task_id} {spec}. Blank = built-in default.",
+ "fieldname": "agent_implement_prompt",
+ "fieldtype": "Code",
+ "label": "Implement Prompt",
+ "options": "Text"
+ },
+ {
+ "description": "Global review-feedback prompt. Tokens: {title} {spec_path} {branch} {project} {task_id} {comments}. Blank = built-in default.",
+ "fieldname": "agent_changes_prompt",
+ "fieldtype": "Code",
+ "label": "Changes Prompt",
+ "options": "Text"
+ },
+ {
+ "fieldname": "agent_lifecycle_section",
+ "fieldtype": "Section Break",
+ "label": "Agent Lifecycle & Cost Control (Phase 5)"
+ },
+ {
+ "default": "5",
+ "description": "Hard cap on live agent boxes (non-terminal agent_status). Tasks beyond the cap stay Queued and are drained by the watchdog. specs/v2 06-phase-5.",
+ "fieldname": "max_concurrent_agent_boxes",
+ "fieldtype": "Int",
+ "label": "Max Concurrent Agent Boxes"
+ },
+ {
+ "default": "15",
+ "description": "Queued/Provisioning watchdog budget (minutes); breach -> Failed. 0 disables.",
+ "fieldname": "provisioning_timeout_minutes",
+ "fieldtype": "Int",
+ "label": "Provisioning Timeout (minutes)"
+ },
+ {
+ "default": "30",
+ "description": "Spec In Progress watchdog backstop (minutes); breach -> Failed. 0 disables.",
+ "fieldname": "spec_timeout_minutes",
+ "fieldtype": "Int",
+ "label": "Spec Timeout (minutes)"
+ },
+ {
+ "default": "45",
+ "description": "Implementing/Changes Requested watchdog backstop (minutes); breach -> Failed. 0 disables.",
+ "fieldname": "implement_timeout_minutes",
+ "fieldtype": "Int",
+ "label": "Implement Timeout (minutes)"
+ },
+ {
+ "default": "6",
+ "description": "Tear down a box parked in a human-wait state (Spec Created/Approved/PR Ready) this long (hours). 0 disables.",
+ "fieldname": "idle_teardown_hours",
+ "fieldtype": "Int",
+ "label": "Idle Teardown (hours)"
+ },
+ {
+ "default": "2",
+ "description": "How long to keep a Failed box for debugging before the watchdog sweeps it (hours). 0 disables.",
+ "fieldname": "failed_teardown_grace_hours",
+ "fieldtype": "Int",
+ "label": "Failed Teardown Grace (hours)"
+ },
+ {
+ "fieldname": "telegram_section",
+ "fieldtype": "Section Break",
+ "label": "Notifications (Telegram)"
+ },
+ {
+ "default": "0",
+ "fieldname": "notifications_enabled",
+ "fieldtype": "Check",
+ "label": "Notifications Enabled"
+ },
+ {
+ "fieldname": "telegram_bot_token",
+ "fieldtype": "Password",
+ "label": "Telegram Bot Token"
+ },
+ {
+ "fieldname": "telegram_default_chat_id",
+ "fieldtype": "Data",
+ "label": "Telegram Default Chat ID"
}
],
"grid_page_length": 50,
diff --git a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py
index 5435f84..27e2e70 100644
--- a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py
+++ b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py
@@ -15,13 +15,34 @@ class HiveSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ agent_callback_api_key: DF.Data | None
+ agent_callback_api_secret: DF.Password | None
+ agent_changes_prompt: DF.Code | None
+ agent_implement_prompt: DF.Code | None
+ agent_orchestration_enabled: DF.Check
+ agent_spec_prompt: DF.Code | None
+ anthropic_api_key: DF.Password | None
app_name: DF.Data | None
+ benchspace_api_key: DF.Data | None
+ benchspace_api_secret: DF.Password | None
+ benchspace_api_url: DF.Data | None
+ default_agent_template_slug: DF.Data | None
+ failed_teardown_grace_hours: DF.Int
github_app_client_id: DF.Data | None
github_app_client_secret: DF.Password | None
github_app_id: DF.Data | None
github_app_public_link: DF.Data | None
+ idle_teardown_hours: DF.Int
+ implement_timeout_minutes: DF.Int
lock_due_date_on_or_after: DF.Check
+ max_concurrent_agent_boxes: DF.Int
+ notifications_enabled: DF.Check
onboarding_completed: DF.Check
+ provisioning_timeout_minutes: DF.Int
+ skills_repo: DF.Data | None
+ spec_timeout_minutes: DF.Int
+ telegram_bot_token: DF.Password | None
+ telegram_default_chat_id: DF.Data | None
# end: auto-generated types
@frappe.whitelist()
diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js
index 4ae751c..3c6662f 100644
--- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js
+++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js
@@ -1,8 +1,173 @@
// Copyright (c) 2026, BWH Studios and contributors
// For license information, please see license.txt
-// frappe.ui.form.on("Hive Task", {
-// refresh(frm) {
+// Minimal agent desk UX (specs/v2 §E.10). Approve/Changes/Merge buttons come online
+// in Phases 3–4; for now we surface the agent state and quick links.
+frappe.ui.form.on("Hive Task", {
+ refresh(frm) {
+ if (!frm.doc.agent_status) {
+ return;
+ }
-// },
-// });
+ const colors = {
+ Queued: "gray",
+ Provisioning: "blue",
+ "Spec In Progress": "blue",
+ "Spec Created": "orange",
+ "Spec Approved": "blue",
+ Implementing: "blue",
+ "PR Ready": "green",
+ "Changes Requested": "orange",
+ Merged: "green",
+ Cancelled: "gray",
+ Failed: "red",
+ };
+
+ let banner = __("Agent: {0}", [frm.doc.agent_status]);
+ if (frm.doc.agent_last_error) {
+ banner += ` — ${frappe.utils.escape_html(frm.doc.agent_last_error)}`;
+ }
+ frm.dashboard.add_indicator(banner, colors[frm.doc.agent_status] || "gray");
+
+ // Spec review → approval (specs/v2 04-phase-3). Approving dispatches implementation.
+ if (frm.doc.agent_status === "Spec Created") {
+ frm.add_custom_button(__("Approve Spec"), () => {
+ frappe.prompt(
+ [
+ {
+ fieldname: "note",
+ fieldtype: "Small Text",
+ label: __("Note (optional)"),
+ },
+ ],
+ (values) => {
+ frm.call("approve_spec", { note: values.note }).then(() => {
+ frappe.show_alert({
+ message: __("Spec approved — implementation starting."),
+ indicator: "green",
+ });
+ frm.reload_doc();
+ });
+ },
+ __("Approve Spec"),
+ __("Approve")
+ );
+ }).addClass("btn-primary");
+ }
+
+ // PR review → request changes / mark merged (specs/v2 05-phase-4 §B.9).
+ if (frm.doc.agent_status === "PR Ready") {
+ frm.add_custom_button(__("Request Changes"), () => {
+ frappe.prompt(
+ [
+ {
+ fieldname: "body",
+ fieldtype: "Small Text",
+ label: __("Review comments"),
+ reqd: 1,
+ },
+ ],
+ (values) => {
+ const comments = [{ author: frappe.session.user, body: values.body }];
+ frm.call("request_agent_changes", {
+ comments: JSON.stringify(comments),
+ }).then(() => {
+ frappe.show_alert({
+ message: __("Changes requested — agent is iterating."),
+ indicator: "blue",
+ });
+ frm.reload_doc();
+ });
+ },
+ __("Request Changes"),
+ __("Send to Agent")
+ );
+ }).addClass("btn-primary");
+
+ frm.add_custom_button(__("Mark Merged"), () => {
+ frappe.confirm(
+ __("Confirm the PR is merged on GitHub. This marks the task Merged."),
+ () => {
+ frm.call("mark_agent_merged").then(() => {
+ frappe.show_alert({
+ message: __("Task marked Merged."),
+ indicator: "green",
+ });
+ frm.reload_doc();
+ });
+ }
+ );
+ });
+ }
+
+ // Failed → recovery affordances (specs/v2 06-phase-5 step 9).
+ if (frm.doc.agent_status === "Failed") {
+ frm.add_custom_button(__("Retry"), () => {
+ frappe.confirm(
+ __(
+ "Tear down the old box (if any) and re-provision a fresh box for this task?"
+ ),
+ () => {
+ frm.call("retry_agent").then(() => {
+ frappe.show_alert({
+ message: __("Retry queued — provisioning a fresh box."),
+ indicator: "blue",
+ });
+ frm.reload_doc();
+ });
+ }
+ );
+ }).addClass("btn-primary");
+
+ frm.add_custom_button(__("Tear Down Now"), () => {
+ frappe.confirm(
+ __("Deprovision this box now instead of waiting for the grace sweep?"),
+ () => {
+ frm.call("teardown_agent_now").then(() => {
+ frappe.show_alert({
+ message: __("Teardown requested."),
+ indicator: "orange",
+ });
+ frm.reload_doc();
+ });
+ }
+ );
+ });
+ }
+
+ // Any non-terminal agent task can be cancelled (specs/v2 06-phase-5 step 2).
+ const TERMINAL = ["Merged", "Cancelled", "Failed"];
+ if (!TERMINAL.includes(frm.doc.agent_status)) {
+ frm.add_custom_button(__("Cancel Agent Task"), () => {
+ frappe.confirm(__("Cancel this agent task? The box will be torn down."), () => {
+ frm.call("cancel_agent").then(() => {
+ frappe.show_alert({
+ message: __("Task cancelled — box being torn down."),
+ indicator: "gray",
+ });
+ frm.reload_doc();
+ });
+ });
+ });
+ }
+
+ const open = (url) => url && window.open(url, "_blank");
+ if (frm.doc.agent_code_url) {
+ frm.add_custom_button(
+ __("Open Code (spec)"),
+ () => open(frm.doc.agent_code_url),
+ __("Agent")
+ );
+ }
+ if (frm.doc.agent_site_url) {
+ frm.add_custom_button(
+ __("Open Site"),
+ () => open(frm.doc.agent_site_url),
+ __("Agent")
+ );
+ }
+ if (frm.doc.pr_link) {
+ frm.add_custom_button(__("Open PR"), () => open(frm.doc.pr_link), __("Agent"));
+ }
+ },
+});
diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json
index 51a96cf..6327268 100644
--- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json
+++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json
@@ -28,7 +28,19 @@
"uat_status",
"uat_approved_by",
"uat_date",
- "github_issue_url"
+ "github_issue_url",
+ "agent_section",
+ "agent_status",
+ "agent_dev_box",
+ "agent_box_slug",
+ "agent_control_url",
+ "agent_control_token",
+ "agent_site_url",
+ "agent_code_url",
+ "agent_spec_path",
+ "agent_branch",
+ "agent_last_error",
+ "agent_box_torn_down"
],
"fields": [
{
@@ -186,6 +198,86 @@
"label": "GitHub Issue URL",
"options": "URL",
"read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "eval:doc.agent_status",
+ "fieldname": "agent_section",
+ "fieldtype": "Section Break",
+ "label": "Agent"
+ },
+ {
+ "default": "",
+ "description": "Agent lifecycle state (specs/v2). Empty = not agent-managed. Separate from the kanban status.",
+ "fieldname": "agent_status",
+ "fieldtype": "Select",
+ "label": "Agent Status",
+ "options": "\nQueued\nProvisioning\nSpec In Progress\nSpec Created\nSpec Approved\nImplementing\nPR Ready\nChanges Requested\nMerged\nCancelled\nFailed",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_dev_box",
+ "fieldtype": "Data",
+ "label": "Agent Dev Box",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_box_slug",
+ "fieldtype": "Data",
+ "label": "Agent Box Slug",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_control_url",
+ "fieldtype": "Data",
+ "label": "Agent Control URL",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_control_token",
+ "fieldtype": "Password",
+ "label": "Agent Control Token",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_site_url",
+ "fieldtype": "Data",
+ "label": "Agent Site URL",
+ "options": "URL",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_code_url",
+ "fieldtype": "Data",
+ "label": "Agent Code URL",
+ "options": "URL",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_spec_path",
+ "fieldtype": "Data",
+ "label": "Agent Spec Path",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_branch",
+ "fieldtype": "Data",
+ "label": "Agent Branch",
+ "read_only": 1
+ },
+ {
+ "fieldname": "agent_last_error",
+ "fieldtype": "Small Text",
+ "label": "Agent Last Error",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "description": "Set by deprovision_for_task on a successful teardown; the watchdog's terminal-teardown sweep skips boxes already torn down (Phase 5).",
+ "fieldname": "agent_box_torn_down",
+ "fieldtype": "Check",
+ "label": "Agent Box Torn Down",
+ "read_only": 1
}
],
"grid_page_length": 50,
diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py
index 26690cd..35b721f 100644
--- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py
+++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py
@@ -45,6 +45,30 @@ class HiveTask(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ agent_box_slug: DF.Data | None
+ agent_box_torn_down: DF.Check
+ agent_branch: DF.Data | None
+ agent_code_url: DF.Data | None
+ agent_control_token: DF.Password | None
+ agent_control_url: DF.Data | None
+ agent_dev_box: DF.Data | None
+ agent_last_error: DF.SmallText | None
+ agent_site_url: DF.Data | None
+ agent_spec_path: DF.Data | None
+ agent_status: DF.Literal[
+ "",
+ "Queued",
+ "Provisioning",
+ "Spec In Progress",
+ "Spec Created",
+ "Spec Approved",
+ "Implementing",
+ "PR Ready",
+ "Changes Requested",
+ "Merged",
+ "Cancelled",
+ "Failed",
+ ]
assigned_to: DF.Link | None
completed_on: DF.Date | None
depends_on: DF.Link | None
@@ -180,6 +204,82 @@ def _maybe_spawn_recurrence(self):
message=f"Failed to assign {assignees} to {new_task.name}",
)
+ @frappe.whitelist()
+ def approve_spec(self, note: str | None = None) -> dict:
+ """Human approval of the agent's spec (specs/v2 04-phase-3 §A.1).
+
+ Routes through the orchestrator so the transition is validated and the
+ implementation run is dispatched. Mirrors approve_uat's desk-action shape.
+ """
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ self._assert_agent_reviewer()
+ service.set_agent_status(self, "Spec Approved", actor="human", message=note)
+ return {"ok": True, "agent_status": "Spec Approved"}
+
+ @frappe.whitelist()
+ def request_agent_changes(self, comments: str | list) -> dict:
+ """Human requests another iteration on a PR Ready task (specs/v2 05-phase-4 §B.6).
+
+ `comments` is the §5.3 payload — a JSON array (or list) of {author, body, path?, line?}.
+ Routes through the orchestrator, which transitions the task and dispatches /changes/apply.
+ """
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ self._assert_agent_reviewer()
+ parsed = comments if isinstance(comments, list) else frappe.parse_json(comments)
+ if not parsed:
+ frappe.throw("Provide at least one review comment.")
+ service.request_changes(self, parsed)
+ return {"ok": True, "agent_status": "Implementing"}
+
+ @frappe.whitelist()
+ def mark_agent_merged(self) -> dict:
+ """Human records that the PR was merged on GitHub (specs/v2 05-phase-4 §B.8)."""
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ self._assert_agent_reviewer()
+ service.mark_merged(self)
+ return {"ok": True, "agent_status": "Merged"}
+
+ @frappe.whitelist()
+ def cancel_agent(self) -> dict:
+ """Human cancels an in-flight agent task (specs/v2 06-phase-5 step 2)."""
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ self._assert_agent_reviewer()
+ service.cancel_agent_task(self.name)
+ return {"ok": True, "agent_status": "Cancelled"}
+
+ @frappe.whitelist()
+ def retry_agent(self) -> dict:
+ """Re-provision a clean box for a Failed agent task (specs/v2 06-phase-5 step 8)."""
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ self._assert_agent_reviewer()
+ return service.retry_agent_task(self.name)
+
+ @frappe.whitelist()
+ def teardown_agent_now(self) -> dict:
+ """Force-deprovision a Failed box ahead of the grace sweep (specs/v2 06-phase-5 step 9)."""
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ self._assert_agent_reviewer()
+ service.force_teardown(self.name)
+ return {"ok": True}
+
+ def _assert_agent_reviewer(self) -> None:
+ """A human reviewer with write access — never the Agent bot itself.
+
+ Identity check, not a role check: Administrator implicitly holds every role, so a
+ role test would wrongly block a legitimate admin reviewer.
+ """
+ from bwh_hive.bwh_hive.orchestrator import service
+
+ if frappe.session.user == service.get_agent_user():
+ frappe.throw("The Agent bot cannot review its own work.", frappe.PermissionError)
+ self.check_permission("write")
+
@frappe.whitelist()
def approve_uat(self):
self.uat_status = "Approved"
diff --git a/bwh_hive/bwh_hive/notifications/__init__.py b/bwh_hive/bwh_hive/notifications/__init__.py
new file mode 100644
index 0000000..14927f5
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/__init__.py
@@ -0,0 +1,10 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Agent-lifecycle notifications (specs/v2 07-notifications.md).
+
+A small, object-oriented dispatcher that turns agent-lifecycle transitions into chat
+alerts. Telegram is the only channel that sends in v2; Email/FrappeLog are real
+subclasses left disabled by default. The orchestration/callback layer imports `notify`
+from `.dispatcher`; nothing here imports back into the orchestrator.
+"""
diff --git a/bwh_hive/bwh_hive/notifications/base.py b/bwh_hive/bwh_hive/notifications/base.py
new file mode 100644
index 0000000..6d3f375
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/base.py
@@ -0,0 +1,28 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""The channel contract (specs/v2 07-notifications.md "base.py")."""
+
+from abc import ABC, abstractmethod
+
+from bwh_hive.bwh_hive.notifications.events import NotificationEvent
+
+
+class NotificationChannel(ABC):
+ name: str # "telegram" | "email" | "frappe_log"
+
+ def __init__(self, settings):
+ # cached Hive Settings doc — no per-send DB round-trip for the token
+ self.settings = settings
+
+ @abstractmethod
+ def is_enabled(self) -> bool:
+ """Channel-level gate (config present + turned on). Cheap, no network."""
+
+ @abstractmethod
+ def render(self, event: NotificationEvent) -> dict:
+ """Turn a NotificationEvent into this channel's payload."""
+
+ @abstractmethod
+ def send(self, event: NotificationEvent) -> None:
+ """Deliver. Runs inside an enqueued job. May raise; the dispatcher isolates it."""
diff --git a/bwh_hive/bwh_hive/notifications/channels/__init__.py b/bwh_hive/bwh_hive/notifications/channels/__init__.py
new file mode 100644
index 0000000..eeb9a8b
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/channels/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
diff --git a/bwh_hive/bwh_hive/notifications/channels/email.py b/bwh_hive/bwh_hive/notifications/channels/email.py
new file mode 100644
index 0000000..a5b9ab3
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/channels/email.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Email channel — a real subclass, disabled by default (07-notifications.md step 5).
+
+`render`/`send` are real (reuse `frappe.sendmail`) so the abstraction is exercised, but
+`is_enabled()` hard-returns False until a later phase turns email on. This keeps the
+fan-out honest without doubling alerts.
+"""
+
+import frappe
+
+from bwh_hive.bwh_hive.notifications.base import NotificationChannel
+from bwh_hive.bwh_hive.notifications.events import NotificationEvent, render_plain_template
+
+
+class EmailChannel(NotificationChannel):
+ name = "email"
+
+ def is_enabled(self) -> bool:
+ # Disabled in v2. Telegram is the only sending channel.
+ return False
+
+ def render(self, event: NotificationEvent) -> dict:
+ return {
+ "subject": f"[Hive Agent] {event.task}: {event.task_title}",
+ "message": render_plain_template(event).replace("\n", "
"),
+ }
+
+ def send(self, event: NotificationEvent) -> None:
+ recipients = self.settings.get("agent_notify_email") or frappe.session.user
+ payload = self.render(event)
+ frappe.sendmail(
+ recipients=recipients,
+ subject=payload["subject"],
+ message=payload["message"],
+ )
diff --git a/bwh_hive/bwh_hive/notifications/channels/frappe_log.py b/bwh_hive/bwh_hive/notifications/channels/frappe_log.py
new file mode 100644
index 0000000..17a5549
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/channels/frappe_log.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Frappe Notification Log channel — a real subclass, disabled by default.
+
+Reuses `enqueue_create_notification` (the same path @mention alerts use) so a reviewer's
+desk bell can later light up on agent events. `is_enabled()` hard-returns False in v2.
+See 07-notifications.md step 5.
+"""
+
+import frappe
+from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification
+
+from bwh_hive.bwh_hive.notifications.base import NotificationChannel
+from bwh_hive.bwh_hive.notifications.events import NotificationEvent, render_plain_template
+
+
+class FrappeLogChannel(NotificationChannel):
+ name = "frappe_log"
+
+ def is_enabled(self) -> bool:
+ # Disabled in v2. Telegram is the only sending channel.
+ return False
+
+ def render(self, event: NotificationEvent) -> dict:
+ return {
+ "subject": render_plain_template(event).split("\n", 1)[0],
+ "type": "Alert",
+ "document_type": "Hive Task",
+ "document_name": event.task,
+ }
+
+ def send(self, event: NotificationEvent) -> None:
+ notification = self.render(event)
+ # Route to the task assignee(s); falls back to the session user if none resolve.
+ recipients = [frappe.session.user]
+ enqueue_create_notification(recipients, notification)
diff --git a/bwh_hive/bwh_hive/notifications/channels/telegram.py b/bwh_hive/bwh_hive/notifications/channels/telegram.py
new file mode 100644
index 0000000..cebd46c
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/channels/telegram.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Telegram channel — the only channel that sends in v2 (07-notifications.md)."""
+
+import requests
+
+from bwh_hive.bwh_hive.notifications.base import NotificationChannel
+from bwh_hive.bwh_hive.notifications.events import NotificationEvent, render_markdown_template
+
+API_BASE = "https://api.telegram.org"
+
+
+class TelegramChannel(NotificationChannel):
+ name = "telegram"
+
+ def is_enabled(self) -> bool:
+ """On only when notifications are enabled AND both token + chat id are present.
+
+ A missing token/chat id is the graceful-off path — a silent no-op, not an error.
+ """
+ return bool(
+ self.settings.notifications_enabled
+ and self.settings.get_password("telegram_bot_token", raise_exception=False)
+ and self.settings.telegram_default_chat_id
+ )
+
+ def render(self, event: NotificationEvent) -> dict:
+ return {
+ "chat_id": self.settings.telegram_default_chat_id,
+ "text": render_markdown_template(event),
+ "parse_mode": "MarkdownV2",
+ "disable_web_page_preview": True,
+ }
+
+ def send(self, event: NotificationEvent) -> None:
+ token = self.settings.get_password("telegram_bot_token")
+ resp = requests.post(f"{API_BASE}/bot{token}/sendMessage", json=self.render(event), timeout=10)
+ if not resp.ok:
+ # Surface Telegram's 4xx body (e.g. "chat not found", "can't parse entities")
+ # so a bad chat id or an escaping bug is diagnosable in the Error Log.
+ raise requests.HTTPError(f"Telegram sendMessage {resp.status_code}: {resp.text}", response=resp)
diff --git a/bwh_hive/bwh_hive/notifications/dispatcher.py b/bwh_hive/bwh_hive/notifications/dispatcher.py
new file mode 100644
index 0000000..4e0533a
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/dispatcher.py
@@ -0,0 +1,79 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""The single public entry point: `notify(event)` (07-notifications.md "dispatcher.py").
+
+Two layers of safety:
+ • `notify()` only *enqueues* (callback returns at network speed) and is itself wrapped so
+ even a broker hiccup logs rather than 500s the callback.
+ • `_deliver()` runs in the worker and catches everything, so a broken channel logs to the
+ Error Log instead of an alert storm or a surfaced exception.
+`enqueue_after_commit=True` means a status callback that rolls back never sends a phantom ping.
+"""
+
+import frappe
+
+from bwh_hive.bwh_hive.notifications.channels.email import EmailChannel
+from bwh_hive.bwh_hive.notifications.channels.frappe_log import FrappeLogChannel
+from bwh_hive.bwh_hive.notifications.channels.telegram import TelegramChannel
+from bwh_hive.bwh_hive.notifications.events import EventType, NotificationEvent
+
+# Optional events fire only when listed here; the three required events always fire when
+# notifications are enabled. Flip these on by adding them to the set — no other change.
+CHATTY_EVENTS: set[EventType] = set() # PROVISIONING / CHANGES_REQ muted by default
+
+_REQUIRED_EVENTS = {EventType.SPEC_CREATED, EventType.PR_READY, EventType.FAILED}
+
+_CHANNEL_CLASSES = {
+ "telegram": TelegramChannel,
+ "email": EmailChannel,
+ "frappe_log": FrappeLogChannel,
+}
+
+
+def notify(event: NotificationEvent) -> None:
+ """Public API. Non-blocking: enqueues one background job per enabled channel.
+
+ Defensive throughout — a notification must never resurface into the callback path.
+ """
+ try:
+ if event.type not in _REQUIRED_EVENTS and event.type not in CHATTY_EVENTS:
+ return # optional event, muted
+ settings = frappe.get_cached_doc("Hive Settings")
+ if not settings.notifications_enabled:
+ return # global kill-switch → no-op
+ for channel in _enabled_channels(settings):
+ frappe.enqueue(
+ "bwh_hive.bwh_hive.notifications.dispatcher._deliver",
+ queue="short",
+ timeout=30,
+ enqueue_after_commit=True, # don't fire on a rolled-back callback
+ channel_name=channel.name,
+ event=event,
+ )
+ except Exception:
+ frappe.log_error(
+ title="Notification enqueue failed",
+ message=frappe.get_traceback(),
+ ) # even the enqueue is best-effort — a broker hiccup cannot 500 a callback
+
+
+def _enabled_channels(settings) -> list:
+ candidates = [cls(settings) for cls in _CHANNEL_CLASSES.values()]
+ return [c for c in candidates if c.is_enabled()]
+
+
+def _build(channel_name: str):
+ settings = frappe.get_cached_doc("Hive Settings")
+ return _CHANNEL_CLASSES[channel_name](settings)
+
+
+def _deliver(channel_name: str, event: NotificationEvent) -> None:
+ """Runs in the worker. One channel, fully isolated."""
+ try:
+ _build(channel_name).send(event)
+ except Exception:
+ frappe.log_error(
+ title=f"Notification failed: {channel_name} / {event.type.value}",
+ message=frappe.get_traceback(),
+ ) # swallow — a broken channel must never resurface
diff --git a/bwh_hive/bwh_hive/notifications/events.py b/bwh_hive/bwh_hive/notifications/events.py
new file mode 100644
index 0000000..354fccb
--- /dev/null
+++ b/bwh_hive/bwh_hive/notifications/events.py
@@ -0,0 +1,174 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""The `NotificationEvent` value object + event catalogue + template renderer.
+
+A `NotificationEvent` is an immutable description of *what happened*, independent of *how*
+it is delivered. Channels render it; they never query Hive themselves (`from_task` reads
+the task once, here). Telegram renders MarkdownV2; the plain-text fallback strips it.
+
+See specs/v2/07-notifications.md "Event catalogue" + "Message templates".
+"""
+
+import dataclasses
+from enum import Enum
+
+import frappe
+from frappe.utils import get_url
+
+
+class EventType(str, Enum):
+ PROVISIONING = "provisioning" # optional (chatty)
+ SPEC_CREATED = "spec_created" # required
+ CHANGES_REQ = "changes_requested" # optional (chatty)
+ PR_READY = "pr_ready" # required
+ FAILED = "failed" # required
+
+
+@dataclasses.dataclass(frozen=True)
+class NotificationEvent:
+ """Immutable, picklable (it is passed across `frappe.enqueue`)."""
+
+ type: EventType
+ task: str # Hive Task name, e.g. "TASK-0007"
+ task_title: str
+ project: str | None = None
+ actor: str | None = None # "agent" or a User; for the footer line
+ message: str | None = None # free-text detail (e.g. error summary)
+ # deep links — populated from the task's agent_* fields where present
+ task_url: str | None = None # {site}/hive/tasks/{task}
+ code_url: str | None = None # box code-server (review spec)
+ site_url: str | None = None # box Frappe site (review changes)
+ pr_url: str | None = None # GitHub PR
+ payload: dict | None = None # channel-specific extras (e.g. {"phase": ...})
+
+ @classmethod
+ def from_task(cls, type: EventType, task_name: str, **overrides) -> "NotificationEvent":
+ """Build an event by reading the agent_* fields off a Hive Task once.
+
+ `overrides` win over the task-derived values (e.g. `message=error`,
+ `payload={"phase": phase}` from `report_agent_error`).
+ """
+ doc = frappe.get_doc("Hive Task", task_name)
+ base = dict(
+ type=type,
+ task=doc.name,
+ task_title=doc.title or doc.name,
+ project=doc.project,
+ task_url=f"{get_url()}/hive/tasks/{doc.name}",
+ code_url=doc.agent_code_url or None,
+ site_url=doc.agent_site_url or None,
+ pr_url=doc.pr_link or None,
+ )
+ base.update(overrides)
+ return cls(**base)
+
+
+# --------------------------------------------------------------------------- #
+# MarkdownV2 rendering (Telegram)
+# --------------------------------------------------------------------------- #
+# Telegram MarkdownV2 reserves these; every dynamic substring must escape them.
+# https://core.telegram.org/bots/api#markdownv2-style
+_MD2_RESERVED = r"_*[]()~`>#+-=|{}.!"
+_MD2_TABLE = {ord(c): "\\" + c for c in _MD2_RESERVED}
+
+
+def escape_md2(text) -> str:
+ """Escape a string for Telegram MarkdownV2. None → empty string."""
+ return str(text or "").translate(_MD2_TABLE)
+
+
+def _link(label: str, url: str | None) -> str | None:
+ """A MarkdownV2 inline link, or None when the URL is absent (graceful degrade)."""
+ if not url:
+ return None
+ # The link *text* is still MarkdownV2 — reserved chars in the label (e.g. the parens
+ # in "Review spec (code)") must be escaped or Telegram rejects with 400 "can't parse
+ # entities". Inside the URL part only `)` and `\` need escaping.
+ safe_url = url.replace("\\", "\\\\").replace(")", "\\)")
+ return f"[{escape_md2(label)}]({safe_url})"
+
+
+def _links_line(*links: str | None) -> str:
+ rendered = [link for link in links if link]
+ return " · ".join(rendered)
+
+
+def render_markdown_template(event: NotificationEvent) -> str:
+ """Render a NotificationEvent to a Telegram MarkdownV2 message body.
+
+ Each template leads with an emoji + headline, names the task, and ends with the
+ available deep links. A link is omitted when its URL field is empty, so an
+ early-lifecycle event degrades to just `[Open task]`.
+ """
+ task = escape_md2(event.task)
+ title = escape_md2(event.task_title)
+ open_task = _link("Open task", event.task_url)
+
+ if event.type is EventType.SPEC_CREATED:
+ links = _links_line(_link("Review spec (code)", event.code_url), open_task)
+ return (
+ f"📝 *Spec ready* — `{task}`\n"
+ f"*{title}*\n"
+ f"The agent finished writing the spec\\. Review it before approving\\.\n\n"
+ f"{links}"
+ )
+
+ if event.type is EventType.PR_READY:
+ links = _links_line(_link("View PR", event.pr_url), _link("Review site", event.site_url), open_task)
+ return (
+ f"✅ *PR ready* — `{task}`\n"
+ f"*{title}*\n"
+ f"The agent opened a pull request\\. Review and merge or request changes\\.\n\n"
+ f"{links}"
+ )
+
+ if event.type is EventType.FAILED:
+ phase = escape_md2((event.payload or {}).get("phase"))
+ error = escape_md2(event.message)
+ detail = f"Phase: {phase}\n" if phase else ""
+ if error:
+ detail += f"{error}\n"
+ return f"🔴 *Agent failed* — `{task}`\n*{title}*\n{detail}\n{_links_line(open_task)}"
+
+ if event.type is EventType.PROVISIONING:
+ return (
+ f"🚀 *Box provisioning* — `{task}`\n"
+ f"*{title}*\n"
+ f"A dev box is spinning up for this task\\.\n\n"
+ f"{_links_line(open_task)}"
+ )
+
+ if event.type is EventType.CHANGES_REQ:
+ links = _links_line(_link("View PR", event.pr_url), open_task)
+ return (
+ f"🔁 *Changes requested* — `{task}`\n"
+ f"*{title}*\n"
+ f"Review comments are being applied; a new push is on the way\\.\n\n"
+ f"{links}"
+ )
+
+ # Unknown type — degrade to a minimal, escaped notice rather than raise.
+ return f"*{escape_md2(event.type)}* — `{task}`\n*{title}*"
+
+
+def render_plain_template(event: NotificationEvent) -> str:
+ """A plain-text rendering (Email / Notification Log fallback). No MarkdownV2 escaping."""
+ lines = {
+ EventType.SPEC_CREATED: "Spec ready for review",
+ EventType.PR_READY: "PR ready for review",
+ EventType.FAILED: "Agent failed",
+ EventType.PROVISIONING: "Box provisioning",
+ EventType.CHANGES_REQ: "Changes requested",
+ }
+ headline = lines.get(event.type, str(event.type))
+ body = f"{headline} — {event.task}: {event.task_title}"
+ if event.type is EventType.FAILED:
+ phase = (event.payload or {}).get("phase")
+ if phase:
+ body += f"\nPhase: {phase}"
+ if event.message:
+ body += f"\n{event.message}"
+ if event.task_url:
+ body += f"\n{event.task_url}"
+ return body
diff --git a/bwh_hive/bwh_hive/orchestrator/__init__.py b/bwh_hive/bwh_hive/orchestrator/__init__.py
new file mode 100644
index 0000000..16780bb
--- /dev/null
+++ b/bwh_hive/bwh_hive/orchestrator/__init__.py
@@ -0,0 +1,16 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""v2 agent orchestration (specs/v2/02-phase-1-hive-orchestration.md).
+
+This package owns the agent lifecycle on the Hive side:
+
+- `benchspace.BenchSpaceClient` — thin REST client to BenchSpace (provision/manage boxes).
+- `service` — the core: boot-env assembly, provisioning, the `agent_status` state
+ machine (`set_agent_status`), control-plane dispatch, and (stub) deprovision.
+- `hooks` — doc-event glue that turns "assign the Agent user" into a provision and
+ "unassign" into a teardown.
+
+Box callbacks (`bwh_hive.bwh_hive.agent_api`) and human/desk actions both route status
+changes through `service.set_agent_status` so transitions stay centralized and valid.
+"""
diff --git a/bwh_hive/bwh_hive/orchestrator/benchspace.py b/bwh_hive/bwh_hive/orchestrator/benchspace.py
new file mode 100644
index 0000000..93f0321
--- /dev/null
+++ b/bwh_hive/bwh_hive/orchestrator/benchspace.py
@@ -0,0 +1,71 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Thin BenchSpace REST client (specs/v2 §5.2).
+
+Reads creds from Hive Settings and wraps the `benchspace.api.agent_box` methods with
+token auth (§2.1), a short timeout, and a typed `BenchSpaceError` on any non-2xx so the
+orchestrator can surface failures as `agent_last_error`.
+"""
+
+import frappe
+import requests
+
+TIMEOUT = 30
+
+
+class BenchSpaceError(frappe.ValidationError):
+ pass
+
+
+class BenchSpaceClient:
+ def __init__(self):
+ settings = frappe.get_cached_doc("Hive Settings")
+ self.base_url = (settings.benchspace_api_url or "").rstrip("/")
+ self.api_key = settings.benchspace_api_key
+ self.api_secret = settings.get_password("benchspace_api_secret", raise_exception=False)
+ if not (self.base_url and self.api_key and self.api_secret):
+ raise BenchSpaceError("BenchSpace API credentials are not configured in Hive Settings")
+
+ def _headers(self) -> dict:
+ return {
+ "Authorization": f"token {self.api_key}:{self.api_secret}",
+ "Content-Type": "application/json",
+ }
+
+ def _call(self, method: str, *, http: str, payload: dict) -> dict:
+ url = f"{self.base_url}/api/method/{method}"
+ try:
+ if http == "GET":
+ resp = requests.get(url, params=payload, headers=self._headers(), timeout=TIMEOUT)
+ else:
+ resp = requests.post(url, json=payload, headers=self._headers(), timeout=TIMEOUT)
+ except requests.RequestException as e:
+ raise BenchSpaceError(f"BenchSpace unreachable calling {method}: {e}") from e
+
+ if resp.status_code >= 400:
+ raise BenchSpaceError(f"{method} failed ({resp.status_code}): {resp.text[:500]}")
+
+ try:
+ data = resp.json()
+ except ValueError as e:
+ raise BenchSpaceError(f"{method} returned non-JSON: {resp.text[:200]}") from e
+ # Frappe wraps whitelisted return values under "message".
+ return data.get("message", data)
+
+ def provision(self, template: str, boot_env: dict, owner_user: str | None = None) -> dict:
+ return self._call(
+ "benchspace.api.agent_box.provision",
+ http="POST",
+ payload={"template": template, "boot_env": boot_env, "owner_user": owner_user},
+ )
+
+ def get_box(self, name: str) -> dict:
+ return self._call("benchspace.api.agent_box.get_box", http="GET", payload={"name": name})
+
+ def list_agent_boxes(self) -> list[dict]:
+ result = self._call("benchspace.api.agent_box.list_agent_boxes", http="GET", payload={})
+ return result if isinstance(result, list) else []
+
+ def deprovision(self, name: str) -> dict:
+ return self._call("benchspace.api.agent_box.deprovision", http="POST", payload={"name": name})
diff --git a/bwh_hive/bwh_hive/orchestrator/hooks.py b/bwh_hive/bwh_hive/orchestrator/hooks.py
new file mode 100644
index 0000000..94cfdf7
--- /dev/null
+++ b/bwh_hive/bwh_hive/orchestrator/hooks.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Doc-event glue that turns Agent assignment into provisioning (specs/v2 §C.8).
+
+Hive assigns via Frappe's standard `assign_to.add`, which creates a **ToDo** and updates
+the document's `_assign` field directly — it does NOT run the Hive Task's `on_update`.
+So we hook the **ToDo** doctype (not Hive Task) to reliably observe assignment changes,
+filter to Hive Task ToDos allocated to the Agent user, and route into the orchestrator.
+
+Idempotency lives in the orchestrator: `on_agent_assigned` no-ops if the task is already
+agent-managed; `on_agent_unassigned` no-ops if it is unset or already terminal. So firing
+on every relevant ToDo event is safe.
+"""
+
+import frappe
+
+from bwh_hive.bwh_hive.orchestrator import service
+
+# ToDo statuses that mean "assignment is no longer active".
+_INACTIVE_TODO_STATUS = {"Cancelled", "Closed"}
+
+
+def on_todo_change(doc, method: str) -> None:
+ """React to ToDo create/update/trash for Agent-assigned Hive Tasks."""
+ if doc.reference_type != "Hive Task" or not doc.reference_name:
+ return
+
+ agent_user = service.get_agent_user()
+ if not agent_user or doc.allocated_to != agent_user:
+ return
+
+ if not frappe.db.exists("Hive Task", doc.reference_name):
+ return
+
+ # Assignment is "active" only on an open ToDo that still exists.
+ active = method != "on_trash" and (doc.status not in _INACTIVE_TODO_STATUS)
+
+ if active:
+ service.on_agent_assigned(doc.reference_name)
+ else:
+ service.on_agent_unassigned(doc.reference_name)
diff --git a/bwh_hive/bwh_hive/orchestrator/service.py b/bwh_hive/bwh_hive/orchestrator/service.py
new file mode 100644
index 0000000..a3673d7
--- /dev/null
+++ b/bwh_hive/bwh_hive/orchestrator/service.py
@@ -0,0 +1,568 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+"""Agent orchestration core (specs/v2 §4, §5.3).
+
+Owns: boot-env assembly, async provisioning, the `agent_status` state machine, control
+-plane dispatch, and (Phase 1 stub) deprovision. Box callbacks and human/desk actions
+both funnel status changes through `set_agent_status`, which is the single place that
+validates transitions and fires side effects (notifications, teardown, control dispatch).
+"""
+
+import frappe
+import requests
+from frappe.model.document import Document
+
+from bwh_hive.bwh_hive.orchestrator.benchspace import BenchSpaceClient, BenchSpaceError
+
+AGENT_BOT_ROLE = "Agent Bot"
+
+# Fallback cap when Hive Settings.max_concurrent_agent_boxes is unset (specs/v2 06-phase-5 §3).
+DEFAULT_MAX_CONCURRENT_BOXES = 5
+
+CONTROL_TIMEOUT = 30
+
+# specs/v2 §4.2 — terminal states. Merged/Cancelled tear down immediately; Failed is kept
+# for a grace period (debuggability) and swept by the watchdog (06-phase-5 step 2).
+TERMINAL_STATES = {"Merged", "Cancelled", "Failed"}
+
+# Terminal states whose `_react` tears the box down synchronously (Failed waits for the
+# watchdog's grace sweep — 06-phase-5 pass D).
+IMMEDIATE_TEARDOWN_STATES = {"Merged", "Cancelled"}
+
+# Non-terminal agent states (everything the watchdog considers "live"). Queued is live but
+# owns no box yet; PROVISIONED_STATES are the ones that hold a real box against the cap.
+LIVE_STATES = {
+ "Queued",
+ "Provisioning",
+ "Spec In Progress",
+ "Spec Created",
+ "Spec Approved",
+ "Implementing",
+ "PR Ready",
+ "Changes Requested",
+}
+PROVISIONED_STATES = LIVE_STATES - {"Queued"}
+
+# Allowed transitions, keyed by current state (specs/v2 §4.2). `Failed` and `Cancelled`
+# are reachable from any non-terminal state and are injected below. `Failed → Queued` is
+# the single sanctioned retry edge (06-phase-5 step 8).
+_BASE_TRANSITIONS: dict[str, set[str]] = {
+ "": {"Queued"},
+ "Queued": {"Provisioning"},
+ "Provisioning": {"Provisioning", "Spec In Progress"},
+ "Spec In Progress": {"Spec Created"},
+ "Spec Created": {"Spec Approved"},
+ "Spec Approved": {"Implementing"},
+ "Implementing": {"PR Ready"},
+ "PR Ready": {"Changes Requested", "Merged"},
+ "Changes Requested": {"Implementing"},
+ "Failed": {"Queued"}, # retry only
+}
+
+
+def _allowed_targets(current: str) -> set[str]:
+ targets = set(_BASE_TRANSITIONS.get(current, set()))
+ if current not in TERMINAL_STATES:
+ targets |= {"Failed", "Cancelled"}
+ return targets
+
+
+# Which actor may drive which target (specs/v2 §4.2). The orchestrator is trusted and
+# may set anything reachable; box and human are constrained.
+ACTOR_TARGETS: dict[str, set[str]] = {
+ "box": {"Provisioning", "Spec In Progress", "Spec Created", "PR Ready", "Failed"},
+ "human": {"Spec Approved", "Changes Requested", "Merged"},
+ # "orchestrator" is unrestricted (handled in set_agent_status).
+}
+
+
+class InvalidAgentTransition(frappe.ValidationError):
+ pass
+
+
+# --------------------------------------------------------------------------- #
+# Identity helpers
+# --------------------------------------------------------------------------- #
+def get_agent_user() -> str | None:
+ """Return the enabled User carrying the Agent Bot role (specs/v2 §2.2)."""
+ users = frappe.get_all(
+ "Has Role",
+ filters={"role": AGENT_BOT_ROLE, "parenttype": "User"},
+ pluck="parent",
+ )
+ enabled = [u for u in users if frappe.db.get_value("User", u, "enabled")]
+ candidates = enabled or users
+ # Prefer a dedicated bot over Administrator if both somehow carry the role.
+ for u in candidates:
+ if u != "Administrator":
+ return u
+ return candidates[0] if candidates else None
+
+
+# --------------------------------------------------------------------------- #
+# Boot-env assembly (specs/v2 §3)
+# --------------------------------------------------------------------------- #
+def build_boot_env(task: Document) -> dict:
+ """Assemble the MMDS agent context from Task + Project + Settings.
+
+ Generates a fresh per-box CONTROL_TOKEN. All values are strings (MMDS env map).
+ """
+ project = frappe.get_doc("Hive Project", task.project)
+ settings = frappe.get_cached_doc("Hive Settings")
+ skills_repo = project.get("skills_repo_override") or settings.skills_repo
+
+ # Box-side self-timeout budgets (seconds), kept a few minutes BELOW the Hive watchdog
+ # budgets so the box reports report_agent_error first and the watchdog is pure backstop
+ # (specs/v2 06-phase-5 step 7). 0 setting → fall back to the field default.
+ spec_min = settings.spec_timeout_minutes or 30
+ impl_min = settings.implement_timeout_minutes or 45
+ spec_run_timeout = max(spec_min - 5, 5) * 60
+ impl_run_timeout = max(impl_min - 5, 5) * 60
+
+ env = {
+ "AGENT_MODE": "1",
+ "HIVE_BASE_URL": frappe.utils.get_url(),
+ "HIVE_API_KEY": settings.agent_callback_api_key or "",
+ "HIVE_API_SECRET": settings.get_password("agent_callback_api_secret", raise_exception=False) or "",
+ "HIVE_TASK_ID": task.name,
+ "HIVE_PROJECT": project.get("slug") or project.name,
+ "CONTROL_TOKEN": frappe.generate_hash(length=48),
+ "GIT_REPO": project.get("github_repo") or "",
+ "GIT_PAT": project.get_password("github_pat", raise_exception=False) or "",
+ "TARGET_APP_NAME": project.get("target_app_name") or "",
+ "TARGET_APP_REPO": project.get("target_app_repo") or "",
+ "TARGET_APP_BRANCH": project.get("target_app_branch") or "develop",
+ "SKILLS_REPO": skills_repo or "",
+ "ANTHROPIC_API_KEY": settings.get_password("anthropic_api_key", raise_exception=False) or "",
+ "SPEC_RUN_TIMEOUT": spec_run_timeout,
+ "IMPL_RUN_TIMEOUT": impl_run_timeout,
+ }
+ return {k: str(v) for k, v in env.items()}
+
+
+# --------------------------------------------------------------------------- #
+# Provisioning (async; enqueued from the assignment hook)
+# --------------------------------------------------------------------------- #
+def provision_for_task(task_name: str) -> None:
+ """Provision a BenchSpace box for a Queued task (idempotent, enqueued).
+
+ Guards: orchestration enabled, project agent-enabled, not already provisioned,
+ under the concurrency cap. On success stores box coordinates + control token and
+ advances to Provisioning. On BenchSpace failure, moves the task to Failed.
+ """
+ task = frappe.get_doc("Hive Task", task_name)
+ settings = frappe.get_cached_doc("Hive Settings")
+
+ if not settings.agent_orchestration_enabled:
+ return
+ if task.agent_dev_box:
+ return # already provisioned — re-assignment no-op (specs/v2 decision)
+ if task.agent_status != "Queued":
+ # The task was unassigned/cancelled (reset to "" or a terminal state) between the
+ # enqueue and now — don't provision a box nobody is waiting for. Only a Queued task
+ # is a live provisioning request (06-phase-5: unassign-race guard).
+ return
+
+ project = frappe.get_doc("Hive Project", task.project)
+ if not project.agent_enabled:
+ _fail(task, "Project is not agent-enabled.")
+ return
+
+ cap = settings.max_concurrent_agent_boxes or DEFAULT_MAX_CONCURRENT_BOXES
+ live_boxes = frappe.db.count("Hive Task", {"agent_status": ["in", list(PROVISIONED_STATES)]})
+ if live_boxes >= cap:
+ _comment(task, f"At concurrency cap ({cap} live boxes); staying Queued.")
+ return
+
+ boot_env = build_boot_env(task)
+ control_token = boot_env["CONTROL_TOKEN"]
+ template = project.get("agent_template_slug") or settings.default_agent_template_slug
+ if not template:
+ _fail(task, "No agent template configured.")
+ return
+
+ try:
+ box = BenchSpaceClient().provision(template, boot_env)
+ except Exception as e: # any failure routes to Failed + audit log
+ frappe.log_error(title=f"Agent provision failed: {task_name}", message=str(e))
+ _fail(task, f"Provision failed: {e}")
+ return
+
+ # Persist box coordinates + control token in a single save (encrypts the Password).
+ task.agent_dev_box = box.get("name")
+ task.agent_box_slug = box.get("slug")
+ task.agent_control_url = box.get("control_url")
+ task.agent_site_url = box.get("site_url")
+ task.agent_code_url = box.get("code_url")
+ task.agent_control_token = control_token
+ task.save(ignore_permissions=True)
+
+ set_agent_status(
+ task, "Provisioning", actor="orchestrator", message=f"Box {box.get('name')} provisioning."
+ )
+
+
+# --------------------------------------------------------------------------- #
+# State machine (specs/v2 §4.2)
+# --------------------------------------------------------------------------- #
+def set_agent_status(task, new_status: str, actor: str, message: str | None = None) -> None:
+ """Validate and apply an agent_status transition, then fire side effects.
+
+ `actor` ∈ {"box", "human", "orchestrator"}. Raises on an illegal transition or an
+ actor that is not permitted to reach `new_status`.
+ """
+ task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task)
+ current = task_doc.agent_status or ""
+
+ if current == new_status:
+ if message:
+ _comment(task_doc, message)
+ return
+
+ if new_status not in _allowed_targets(current):
+ frappe.throw(
+ f"Invalid agent_status transition: {current or '(empty)'} → {new_status}",
+ InvalidAgentTransition,
+ )
+ if actor != "orchestrator" and new_status not in ACTOR_TARGETS.get(actor, set()):
+ frappe.throw(
+ f"Actor '{actor}' may not set agent_status to {new_status}",
+ frappe.PermissionError,
+ )
+
+ task_doc.db_set("agent_status", new_status)
+ if message:
+ _comment(task_doc, message)
+ _notify(task_doc, new_status)
+ _publish_agent_update(task_doc, new_status)
+ _react(task_doc, new_status, actor)
+
+
+def _publish_agent_update(task: Document, new_status: str) -> None:
+ """Push a realtime event so the Hive React frontend updates live (specs/v2 09).
+
+ Fired from the single transition choke point, so it covers every status change —
+ box callbacks (spec/pr/error), human reviewer actions, and the watchdog. The payload
+ is intentionally minimal: clients refetch the task/list to pick up all the fields
+ (urls, pr_link, last_error) that a transition sets in the same transaction.
+ `after_commit=True` guarantees that refetch reads the committed row. Broadcast to the
+ whole site (all Desk users), so a reviewer sees a box's progress without a refresh.
+ """
+ frappe.publish_realtime(
+ "hive_agent_update",
+ {"task": task.name, "project": task.project, "agent_status": new_status},
+ after_commit=True,
+ )
+
+
+def _react(task: Document, new_status: str, actor: str) -> None:
+ """Post-transition side effects.
+
+ Merged/Cancelled tear the box down immediately; Failed keeps its box for the grace
+ period and is swept by the watchdog (specs/v2 06-phase-5 step 2 / pass D). Entering
+ Spec Approved kicks off the implementation run (Phase 3). The Changes Requested →
+ /changes/apply dispatch is Phase 4.
+ """
+ if new_status in IMMEDIATE_TEARDOWN_STATES:
+ if task.agent_dev_box:
+ frappe.enqueue(
+ "bwh_hive.bwh_hive.orchestrator.service.deprovision_for_task",
+ queue="long",
+ enqueue_after_commit=True,
+ task_name=task.name,
+ )
+ return
+ if new_status in TERMINAL_STATES: # Failed — deferred teardown (grace sweep)
+ return
+ if new_status == "Spec Approved":
+ # Run the implement kickoff in the background: it flips the task to Implementing
+ # and dispatches to the box over HTTP, which must not block the approving request.
+ frappe.enqueue(
+ "bwh_hive.bwh_hive.orchestrator.service.start_implementation_for_task",
+ queue="long",
+ enqueue_after_commit=True,
+ task_name=task.name,
+ )
+ # Changes Requested → Implementing + dispatch /changes/apply is handled inline in
+ # request_changes (it carries the comments payload and surfaces dispatch errors to the
+ # reviewer synchronously), so it is intentionally not enqueued from here.
+
+
+def _notify(task: Document, new_status: str) -> None:
+ """State-machine notification hook — intentionally a no-op.
+
+ Per the locked decision in 07-notifications.md ("Event-driven, not transition-driven
+ generically"), agent alerts are emitted explicitly from the callback methods in
+ `agent_api.py` (which carry the error/phase detail and know which transition matters),
+ NOT from this generic hook. Wiring `notify()` here as well would double-send. Kept as a
+ seam so the state machine stays testable without a Telegram dependency.
+ """
+ return
+
+
+def start_implementation_for_task(task_name: str) -> None:
+ """Flip an approved task to Implementing and dispatch the box (specs/v2 04-phase-3 §A.2).
+
+ Enqueued when a task enters Spec Approved. On a dispatch failure (box unreachable) it
+ reverts to Spec Approved and records the error so the Phase 5 watchdog can retry.
+ Idempotent: a task no longer in Spec Approved is left alone.
+ """
+ task = frappe.get_doc("Hive Task", task_name)
+ if task.agent_status != "Spec Approved":
+ return
+
+ set_agent_status(
+ task, "Implementing", actor="orchestrator", message="Spec approved — dispatching implementation."
+ )
+ try:
+ dispatch(task, "/implement/start", {})
+ except Exception as e:
+ frappe.log_error(title=f"Implement dispatch failed: {task_name}", message=str(e))
+ # Revert directly: Implementing → Spec Approved is not a valid forward transition,
+ # so go around set_agent_status. The watchdog (Phase 5) retries from here.
+ task.db_set("agent_last_error", f"Implement dispatch failed: {e}")
+ task.db_set("agent_status", "Spec Approved")
+ _comment(task, "Implementation dispatch failed; reverted to Spec Approved (will retry).")
+
+
+def request_changes(task, comments: list[dict]) -> None:
+ """Human asks the agent for another PR iteration (specs/v2 05-phase-4 §B.6).
+
+ PR Ready → Changes Requested (human) → Implementing (orchestrator), then dispatch the
+ comments to the box. Dispatch is synchronous so a box-unreachable error surfaces to the
+ reviewer; on failure the task rolls back to PR Ready rather than stranding in Implementing.
+ """
+ task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task)
+
+ set_agent_status(task_doc, "Changes Requested", actor="human", message="Changes requested.")
+ set_agent_status(
+ task_doc, "Implementing", actor="orchestrator", message="Dispatching review changes to the box."
+ )
+ try:
+ dispatch(task_doc, "/changes/apply", {"comments": comments})
+ except Exception as e:
+ frappe.log_error(title=f"Changes dispatch failed: {task_doc.name}", message=str(e))
+ # Revert directly: Implementing → PR Ready is not a valid forward transition, so go
+ # around set_agent_status. The reviewer retries.
+ task_doc.db_set("agent_last_error", f"Changes dispatch failed: {e}")
+ task_doc.db_set("agent_status", "PR Ready")
+ _comment(task_doc, "Changes dispatch failed; reverted to PR Ready.")
+ frappe.throw(f"Could not dispatch changes to the box: {e}")
+
+
+def mark_merged(task) -> None:
+ """Record that the PR was merged (specs/v2 05-phase-4 §B.8).
+
+ Only state — Merged is terminal, so the existing `_react` enqueues deprovision (the
+ `Merged → deprovision` reaction locked in 00-architecture §4.2).
+ """
+ task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task)
+ set_agent_status(task_doc, "Merged", actor="human", message="PR merged.")
+
+
+def deprovision_for_task(task_name: str) -> None:
+ """The single teardown sink (specs/v2 06-phase-5 step 1).
+
+ Idempotent: no-op if the task never had a box; BenchSpace `deprovision` is itself a
+ no-op on an already-deleted/missing box, so the merge transition, unassign hook, and
+ watchdog can all race here harmlessly. Retains the audit fields (agent_dev_box, pr_link,
+ branch, spec_path, URLs, last_error) and clears only the now-dead control credential.
+ """
+ task = frappe.get_doc("Hive Task", task_name)
+ if not task.agent_dev_box or task.agent_box_torn_down:
+ return # never provisioned, or already torn down — no-op
+ try:
+ BenchSpaceClient().deprovision(task.agent_dev_box)
+ except Exception as e: # teardown failures are logged, not fatal
+ # Leave agent_box_torn_down = 0: the box may still be alive, and the watchdog's
+ # terminal-teardown sweep re-attempts a not-yet-torn-down box next tick
+ # (06-phase-5 cleanup table).
+ frappe.log_error(title=f"Agent deprovision failed: {task_name}", message=str(e))
+ return
+
+ # Success (incl. already-deleted box): the control token is now a dead secret (§2.3).
+ # Frappe keeps Password values in `__Auth` (the doc column is just a `*****` placeholder),
+ # so removing it requires remove_encrypted_password — db_set on the field would not clear
+ # the secret. Mark the box torn down so the watchdog sweep skips it (the queryable signal).
+ from frappe.utils.password import remove_encrypted_password
+
+ remove_encrypted_password("Hive Task", task.name, "agent_control_token")
+ task.db_set({"agent_control_token": None, "agent_box_torn_down": 1})
+
+
+# --------------------------------------------------------------------------- #
+# Control-plane dispatch (specs/v2 §5.3)
+# --------------------------------------------------------------------------- #
+def dispatch(task, path: str, body: dict | None = None) -> dict:
+ """POST to the box control plane with the per-box bearer token."""
+ task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task)
+ url = task_doc.agent_control_url
+ token = task_doc.get_password("agent_control_token", raise_exception=False)
+ if not (url and token):
+ raise BenchSpaceError(f"Task {task_doc.name} has no control plane URL/token")
+
+ resp = requests.post(
+ f"{url.rstrip('/')}{path}",
+ json=body or {},
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=CONTROL_TIMEOUT,
+ )
+ resp.raise_for_status()
+ return resp.json() if resp.text else {}
+
+
+# --------------------------------------------------------------------------- #
+# Assignment reactions (called from the ToDo doc-event hook)
+# --------------------------------------------------------------------------- #
+def on_agent_assigned(task_name: str) -> None:
+ """The Agent user was assigned to a task → queue provisioning (idempotent)."""
+ settings = frappe.get_cached_doc("Hive Settings")
+ if not settings.agent_orchestration_enabled:
+ return
+
+ task = frappe.get_doc("Hive Task", task_name)
+ if task.agent_status or task.agent_dev_box:
+ return # already agent-managed — one box per task
+
+ project = frappe.get_doc("Hive Project", task.project)
+ if not project.agent_enabled:
+ return
+
+ set_agent_status(
+ task, "Queued", actor="orchestrator", message="Assigned to Agent — queued for provisioning."
+ )
+ frappe.enqueue(
+ "bwh_hive.bwh_hive.orchestrator.service.provision_for_task",
+ queue="long",
+ enqueue_after_commit=True,
+ task_name=task.name,
+ )
+
+
+def on_agent_unassigned(task_name: str) -> None:
+ """The Agent user was unassigned → cancel + tear down (specs/v2 §4.2).
+
+ A task that was never provisioned (no box) just resets to "" — there's nothing to tear
+ down and it leaves the agent flow cleanly (06-phase-5 step 2).
+ """
+ task = frappe.get_doc("Hive Task", task_name)
+ if not task.agent_status or task.agent_status in TERMINAL_STATES:
+ return
+ if not task.agent_dev_box:
+ task.db_set("agent_status", "")
+ return
+ set_agent_status(task, "Cancelled", actor="orchestrator", message="Agent unassigned — cancelling.")
+
+
+# --------------------------------------------------------------------------- #
+# Desk / lifecycle actions (specs/v2 06-phase-5 steps 2, 8, 9)
+# --------------------------------------------------------------------------- #
+def cancel_agent_task(task_name: str) -> None:
+ """Explicit cancel: transition to Cancelled, drop the Agent assignment, tear down.
+
+ Routes through `set_agent_status` so `_react` enqueues teardown (Cancelled is an
+ immediate-teardown state). Safe at any non-terminal phase, including Queued/Provisioning.
+ """
+ task = frappe.get_doc("Hive Task", task_name)
+ if not task.agent_status:
+ frappe.throw("Task is not agent-managed.")
+ if task.agent_status in TERMINAL_STATES:
+ return # already cancelled/merged/failed — nothing to do
+ set_agent_status(task, "Cancelled", actor="orchestrator", message="Cancelled by user.")
+ _clear_agent_assignment(task)
+
+
+def force_teardown(task_name: str) -> None:
+ """Tear a box down ahead of the watchdog grace sweep ("Tear Down Now" — 06-phase-5 step 9)."""
+ deprovision_for_task(task_name)
+
+
+def retry_agent_task(task_name: str) -> dict:
+ """Re-provision a clean box for a Failed task (specs/v2 06-phase-5 step 8).
+
+ Tears the old box down and clears the box-binding fields BEFORE re-queuing, so
+ `provision_for_task`'s "already provisioned ⇒ no-op" guard can never leave two live
+ boxes. Goes through Queued (not straight to Provisioning) so the concurrency cap still
+ applies. A second rapid retry finds the task at Queued (not Failed) and is rejected.
+ """
+ task = frappe.get_doc("Hive Task", task_name)
+ if task.agent_status != "Failed":
+ frappe.throw("Retry is only available for a Failed task.")
+
+ deprovision_for_task(task.name) # idempotent
+ task.db_set(
+ {
+ "agent_dev_box": None,
+ "agent_box_slug": None,
+ "agent_control_url": None,
+ "agent_control_token": None,
+ "agent_site_url": None,
+ "agent_code_url": None,
+ "agent_last_error": None,
+ "agent_box_torn_down": 0, # fresh box will be tear-down-able again
+ }
+ )
+
+ set_agent_status(task, "Queued", actor="orchestrator", message="Retry requested.")
+ frappe.enqueue(
+ "bwh_hive.bwh_hive.orchestrator.service.provision_for_task",
+ queue="long",
+ enqueue_after_commit=True,
+ task_name=task.name,
+ )
+ return {"ok": True}
+
+
+def _clear_agent_assignment(task: Document) -> None:
+ """Best-effort removal of the Agent user's assignment (ToDo) on a task.
+
+ Removing the ToDo re-fires `on_todo_change` → `on_agent_unassigned`, which no-ops on a
+ terminal task — so this is safe to call right after setting Cancelled.
+ """
+ agent = get_agent_user()
+ if not agent:
+ return
+ try:
+ from frappe.desk.form.assign_to import remove
+
+ remove("Hive Task", task.name, agent)
+ except Exception as e:
+ frappe.log_error(title=f"Clear agent assignment failed: {task.name}", message=str(e))
+
+
+# --------------------------------------------------------------------------- #
+# Internal
+# --------------------------------------------------------------------------- #
+def _fail(task: Document, reason: str) -> None:
+ """Surface an orchestrator-side failure on agent_last_error, then move to Failed.
+
+ The spec (specs/v2 §B.5) requires provision/orchestration errors to land on
+ agent_last_error, not just a comment.
+ """
+ task.db_set("agent_last_error", reason)
+ set_agent_status(task, "Failed", actor="orchestrator", message=reason)
+
+
+def mark_failed(task, reason: str) -> None:
+ """Public watchdog entry point: record `reason` and transition to Failed (idempotent).
+
+ A no-op on an already-terminal task, so the watchdog passes can call it freely without
+ racing each other or the box's own `report_agent_error` (06-phase-5 passes A-C).
+ """
+ doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task)
+ if doc.agent_status in TERMINAL_STATES:
+ return
+ _fail(doc, reason)
+
+
+def _comment(task: Document, content: str) -> None:
+ """Append a lightweight timeline comment on the task."""
+ frappe.get_doc(
+ {
+ "doctype": "Hive Task Comment",
+ "task": task.name,
+ "content": content,
+ "posted_by": frappe.session.user,
+ }
+ ).insert(ignore_permissions=True)
diff --git a/bwh_hive/hooks.py b/bwh_hive/hooks.py
index 600d312..346325d 100644
--- a/bwh_hive/hooks.py
+++ b/bwh_hive/hooks.py
@@ -142,7 +142,14 @@
doc_events = {
"User Invitation": {
"on_update": "bwh_hive.bwh_hive.utils.on_user_invitation_update",
- }
+ },
+ # v2 agent orchestration: assignment to the Agent user is observed via the ToDo
+ # that assign_to.add creates (Hive Task's own on_update does not fire on assign).
+ "ToDo": {
+ "after_insert": "bwh_hive.bwh_hive.orchestrator.hooks.on_todo_change",
+ "on_update": "bwh_hive.bwh_hive.orchestrator.hooks.on_todo_change",
+ "on_trash": "bwh_hive.bwh_hive.orchestrator.hooks.on_todo_change",
+ },
}
# Scheduled Tasks
@@ -152,6 +159,13 @@
"daily": [
"bwh_hive.tasks.daily",
],
+ # v2 agent lifecycle watchdog (specs/v2 06-phase-5 §4): reconcile drift, enforce
+ # timeouts, sweep idle/orphaned boxes, and drain the queue every 10 minutes.
+ "cron": {
+ "*/10 * * * *": [
+ "bwh_hive.tasks.reconcile_agent_tasks",
+ ],
+ },
}
# Testing
diff --git a/bwh_hive/install.py b/bwh_hive/install.py
index 02ce96a..f6eb190 100644
--- a/bwh_hive/install.py
+++ b/bwh_hive/install.py
@@ -1,11 +1,15 @@
import frappe
+AGENT_BOT_ROLE = "Agent Bot"
+AGENT_BOT_USER = "agent@hive.local"
+
def after_install():
"""Bootstrap Hive roles, members, and default project types."""
_ensure_roles()
_bootstrap_system_managers()
_ensure_default_project_types()
+ _ensure_agent_bot()
frappe.db.commit()
@@ -14,6 +18,7 @@ def after_migrate():
_ensure_roles()
_bootstrap_system_managers()
_ensure_default_project_types()
+ _ensure_agent_bot()
_generate_missing_project_slugs()
frappe.db.commit()
@@ -57,6 +62,39 @@ def _bootstrap_system_managers():
).insert(ignore_permissions=True)
+def _ensure_agent_bot():
+ """Ensure the v2 Agent bot role, user, and Hive Member exist (specs/v2 §A.4).
+
+ The bot carries the shared callback service key (00-architecture.md §2.2) and is
+ identified by the `Agent Bot` role. A Hive Member of type Team makes it selectable
+ in assignee pickers, which is what triggers provisioning.
+ """
+ if not frappe.db.exists("Role", AGENT_BOT_ROLE):
+ frappe.get_doc({"doctype": "Role", "role_name": AGENT_BOT_ROLE, "desk_access": 0}).insert(
+ ignore_permissions=True
+ )
+
+ if not frappe.db.exists("User", AGENT_BOT_USER):
+ user = frappe.get_doc(
+ {
+ "doctype": "User",
+ "email": AGENT_BOT_USER,
+ "first_name": "Agent",
+ "user_type": "System User",
+ "send_welcome_email": 0,
+ }
+ )
+ user.insert(ignore_permissions=True)
+
+ if AGENT_BOT_ROLE not in frappe.get_roles(AGENT_BOT_USER):
+ frappe.get_doc("User", AGENT_BOT_USER).add_roles(AGENT_BOT_ROLE)
+
+ if not frappe.db.exists("Hive Member", {"user": AGENT_BOT_USER}):
+ frappe.get_doc(
+ {"doctype": "Hive Member", "user": AGENT_BOT_USER, "type": "Team", "is_active": 1}
+ ).insert(ignore_permissions=True)
+
+
def _generate_missing_project_slugs():
"""Generate slugs for any projects that don't have one yet."""
projects = frappe.get_all(
diff --git a/bwh_hive/tasks.py b/bwh_hive/tasks.py
index b83b45c..ef6e572 100644
--- a/bwh_hive/tasks.py
+++ b/bwh_hive/tasks.py
@@ -1,7 +1,9 @@
import frappe
-from frappe.utils import nowdate
+from frappe.utils import get_datetime, now_datetime, nowdate
from bwh_hive.bwh_hive.api import _enrich_tasks_with_project_titles
+from bwh_hive.bwh_hive.orchestrator import service
+from bwh_hive.bwh_hive.orchestrator.benchspace import BenchSpaceClient, BenchSpaceError
def daily() -> None:
@@ -76,3 +78,205 @@ def send_daily_overdue_notifications() -> None:
},
now=True,
)
+
+
+# --------------------------------------------------------------------------- #
+# Agent lifecycle watchdog (specs/v2 06-phase-5 §5-6)
+# --------------------------------------------------------------------------- #
+# Human-wait states: no human action arriving is normal, so they are not phase-timed
+# (pass A). They are reclaimed by idle teardown (pass C) instead.
+_IDLE_STATES = {"Spec Created", "Spec Approved", "PR Ready"}
+
+# Grace before the watchdog retries teardown of a Merged/Cancelled box whose synchronous
+# teardown job may still be in flight (minutes).
+_TEARDOWN_SETTLE_MINUTES = 5
+
+_DEPROVISION = "bwh_hive.bwh_hive.orchestrator.service.deprovision_for_task"
+_PROVISION = "bwh_hive.bwh_hive.orchestrator.service.provision_for_task"
+
+
+def reconcile_agent_tasks() -> None:
+ """Orchestration-side lifecycle watchdog (specs/v2 06-phase-5 §5).
+
+ Runs on a ~10-minute cron and backstops the box's own self-timeouts: drags timed-out
+ tasks to Failed, reconciles tasks whose box vanished, reclaims idle boxes, sweeps the
+ Failed-grace + terminal-teardown set and orphaned boxes, and drains the Queued backlog
+ under the concurrency cap. Every pass is idempotent.
+ """
+ settings = frappe.get_cached_doc("Hive Settings")
+ if not settings.agent_orchestration_enabled:
+ return
+
+ live = frappe.get_all(
+ "Hive Task",
+ filters={"agent_status": ["in", list(service.LIVE_STATES)]},
+ fields=["name", "agent_status", "agent_dev_box", "modified"],
+ )
+
+ cap = settings.max_concurrent_agent_boxes or service.DEFAULT_MAX_CONCURRENT_BOXES
+ provisioned = frappe.db.count("Hive Task", {"agent_status": ["in", list(service.PROVISIONED_STATES)]})
+ at_cap = provisioned >= cap
+
+ _sweep_timeouts(live, settings, at_cap) # pass A: stuck phase -> Failed
+ _reconcile_vanished_boxes(live) # pass B: box gone -> Failed
+ _sweep_idle(live, settings) # pass C: idle human-wait -> Failed + teardown
+ _sweep_terminal_teardown(settings) # pass D: Failed grace + terminal retry
+ _sweep_orphans() # orphaned boxes whose task is gone
+ _drain_queue(settings, cap, provisioned) # promote Queued -> Provisioning under cap
+
+ if not frappe.flags.in_test:
+ frappe.db.commit()
+
+
+# --- pass A: phase timeouts (backstop) ------------------------------------- #
+def _phase_budget(status: str, settings) -> int:
+ """Per-phase watchdog budget in minutes (0 = no phase timeout for this state)."""
+ if status in ("Queued", "Provisioning"):
+ return settings.provisioning_timeout_minutes or 0
+ if status == "Spec In Progress":
+ return settings.spec_timeout_minutes or 0
+ if status in ("Implementing", "Changes Requested"):
+ return settings.implement_timeout_minutes or 0
+ return 0
+
+
+def _sweep_timeouts(live: list, settings, at_cap: bool) -> None:
+ now = now_datetime()
+ for t in live:
+ budget = _phase_budget(t.agent_status, settings)
+ if budget <= 0:
+ continue
+ # A Queued task held back only because the cap is full is legitimately waiting,
+ # not stuck — don't fail it; the queue drain will promote it when a slot frees.
+ if t.agent_status == "Queued" and at_cap:
+ continue
+ age_min = (now - get_datetime(t.modified)).total_seconds() / 60
+ if age_min >= budget:
+ service.mark_failed(t.name, f"{t.agent_status} timed out after {budget} min")
+
+
+# --- pass B: vanished boxes ------------------------------------------------ #
+def _poll_box_gone(client, name: str):
+ """(gone, status): True/False if known, None if the poll was transient (skip this tick)."""
+ try:
+ info = client.get_box(name)
+ except BenchSpaceError as e:
+ msg = str(e)
+ if "(404)" in msg or "DoesNotExist" in msg or "not found" in msg.lower():
+ return True, "404"
+ return None, None # transient (network / 5xx) — don't fail on one flaky poll
+ status = (info or {}).get("status")
+ return status in ("deleted", "error"), status
+
+
+def _reconcile_vanished_boxes(live: list) -> None:
+ client = _client()
+ if not client:
+ return
+ for t in live:
+ if not t.agent_dev_box or t.agent_status not in service.PROVISIONED_STATES:
+ continue
+ gone, status = _poll_box_gone(client, t.agent_dev_box)
+ if gone:
+ service.mark_failed(t.name, f"Box disappeared (status={status})")
+
+
+# --- pass C: idle teardown (cost) ------------------------------------------ #
+def _sweep_idle(live: list, settings) -> None:
+ hours = settings.idle_teardown_hours or 0
+ if hours <= 0:
+ return
+ now = now_datetime()
+ for t in live:
+ if t.agent_status not in _IDLE_STATES or not t.agent_dev_box:
+ continue
+ age_h = (now - get_datetime(t.modified)).total_seconds() / 3600
+ if age_h >= hours:
+ service.mark_failed(t.name, f"Idle teardown after {hours} h")
+ _enqueue(_DEPROVISION, t.name) # idle overrides the Failed grace — reclaim now
+
+
+# --- pass D: Failed grace + terminal-teardown retry ------------------------ #
+def _sweep_terminal_teardown(settings) -> None:
+ """Tear down terminal tasks whose box has not been torn down yet.
+
+ Failed boxes are kept `failed_teardown_grace_hours` for debugging; Merged/Cancelled
+ boxes should already be gone, so an un-torn-down one means the synchronous teardown
+ failed/raced — retry it after a short settle window. `deprovision_for_task` sets
+ `agent_box_torn_down` only on success, so a torn-down box drops out of this set.
+ """
+ grace_h = settings.failed_teardown_grace_hours or 0
+ rows = frappe.get_all(
+ "Hive Task",
+ filters={
+ "agent_status": ["in", list(service.TERMINAL_STATES)],
+ "agent_dev_box": ["is", "set"],
+ "agent_box_torn_down": 0,
+ },
+ fields=["name", "agent_status", "modified"],
+ )
+ now = now_datetime()
+ for r in rows:
+ age_min = (now - get_datetime(r.modified)).total_seconds() / 60
+ if r.agent_status == "Failed":
+ if grace_h <= 0 or age_min < grace_h * 60:
+ continue
+ elif age_min < _TEARDOWN_SETTLE_MINUTES: # Merged/Cancelled — let the async job finish
+ continue
+ _enqueue(_DEPROVISION, r.name)
+
+
+# --- orphan sweep ---------------------------------------------------------- #
+def _sweep_orphans() -> None:
+ """Deprovision agent boxes no Hive task references (their task was hard-deleted)."""
+ client = _client()
+ if not client:
+ return
+ try:
+ boxes = client.list_agent_boxes()
+ except BenchSpaceError as e:
+ frappe.log_error(title="reconcile: list_agent_boxes failed", message=str(e))
+ return
+ if not boxes:
+ return
+ referenced = set(
+ frappe.get_all("Hive Task", filters={"agent_dev_box": ["is", "set"]}, pluck="agent_dev_box")
+ )
+ for b in boxes:
+ name = b.get("name")
+ if name and name not in referenced:
+ try:
+ client.deprovision(name)
+ except BenchSpaceError as e:
+ frappe.log_error(title=f"reconcile: orphan deprovision failed {name}", message=str(e))
+
+
+# --- queue drain ----------------------------------------------------------- #
+def _drain_queue(settings, cap: int, provisioned: int) -> None:
+ if cap <= 0:
+ return
+ free = cap - provisioned
+ if free <= 0:
+ return
+ queued = frappe.get_all(
+ "Hive Task",
+ filters={"agent_status": "Queued"},
+ order_by="modified asc", # FIFO
+ limit=free,
+ pluck="name",
+ )
+ for name in queued:
+ _enqueue(_PROVISION, name) # provision_for_task re-checks the cap, so it can't overshoot
+
+
+# --- helpers --------------------------------------------------------------- #
+def _client():
+ try:
+ return BenchSpaceClient()
+ except BenchSpaceError as e:
+ frappe.log_error(title="reconcile: BenchSpace client unavailable", message=str(e))
+ return None
+
+
+def _enqueue(method: str, task_name: str) -> None:
+ frappe.enqueue(method, queue="long", enqueue_after_commit=True, task_name=task_name)
diff --git a/frontend/index.html b/frontend/index.html
index 5215906..ad67a00 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -12,7 +12,8 @@