Refine course progression and runtime safety

This commit is contained in:
Haoran
2026-08-11 15:13:13 +08:00
parent b36dbcd84f
commit ab35e59672
83 changed files with 5291 additions and 2267 deletions

View File

@@ -33,11 +33,11 @@ MCPModel Context Protocolは、Agent が外部ツールを発見・呼び
| assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる |
| mcp\_\_server\_\_tool 命名 | 異なる server 間のツール名衝突を防止 |
s15 の Team runtime を土台にし、idle 時の atomic task claim、安全な task-worktree binding、coordination protocol を引き継ぐ。cron scheduling、background bash の lifecycle、完了後に Lead を自動で起こす通知もそのまま残す。本章では `connect_mcp` ツールを追加し、サービスへの接続、ツール発見、ツールプールへの追加を行う。
s15 の Team runtime を土台にし、idle 時の atomic task claim、restart 後も復元できる task-worktree binding、current assignment だけに結び付く plan approval を引き継ぐ。background bash は非ゼロ終了を failure として報告し、作業終了時に command の元の process group を停止する。durable な一回限り cron job は、先に pending delivery として永続化してから queue へ入れ、その prompt を含む model call が成功するまで保持する。本章では `connect_mcp` ツールを追加し、サービスへの接続、ツール発見、ツールプールへの追加を行う。
task-bound worktree はチームメイトのファイルツールに対するデフォルト作業ディレクトリを変更するだけであり、セキュリティサンドボックスではない。
モデルに公開する `remove_worktree` が受け取るのは `name` だけなので、削除できるのは clean な checkout に限られる。変更破棄する場合は、ユーザーが Git を手動実行するか、明示的な確認を経て host が下位の強制削除経路を呼び出す。モデル自身が強制削除を選ぶことはできない
Worktree 削除はモデルに公開しない。user または host が task、assignment、background process、Git state を確認してから cleanup helper を呼ぶ。変更破棄は、user が手動で行う Git 操作、または明示的な確認後に host が行う操作のままである
本章はプロセス内の server handler を登録し、発見から呼び出しまでをオフラインで実行する。各 handler はクライアントが必要とする `tools/list``tools/call` を提供する。
@@ -184,4 +184,4 @@ tools、permissions、hooks、todo、task graph、memory、compact、background
[s17 Integrated Harness](../s17_integrated_harness/) → s01-s16 の仕組みを 1 つの harness に統合。仕組みは多く、loop は 1 つ。
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->
<!-- translation-sync: zh@v7, en@v7, ja@v7 -->

View File

@@ -33,11 +33,11 @@ MCP (Model Context Protocol) defines how agents discover and invoke external too
| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |
| mcp\_\_server\_\_tool naming | Prevents tool name collisions across different servers |
Builds on s15's team runtime: atomic idle task claiming, safe task-bound worktrees, and coordination protocols. It also retains cron scheduling, the background bash lifecycle, and completion notifications that automatically wake the Lead. This chapter adds the `connect_mcp` tool, which connects to a service, discovers its tools, and adds them to the tool pool.
Builds on s15's team runtime: atomic idle task claiming, task-worktree bindings that can recover after a restart, and plan approvals tied to the current assignment. Background bash reports non-zero exits as failures and stops the command's original process group when work ends. A durable one-shot cron job is persisted as pending before it enters the delivery queue and stays there until the model call containing its prompt succeeds. This chapter adds the `connect_mcp` tool, which connects to a service, discovers its tools, and adds them to the tool pool.
A task-bound worktree changes the teammate file tools' default working directory; it is not a security sandbox.
The model-facing `remove_worktree` tool accepts only `name`, so it can remove only a clean checkout. Discarding changes remains a manual Git operation for the user, or a host action that follows explicit confirmation; the model cannot opt into the lower-level force path itself.
Worktree removal is not model-facing. The user or host reviews the task, assignment, background process, and Git state before calling the cleanup helper. Discarding changes remains a manual Git operation or a host action after explicit confirmation.
The chapter registers in-process server handlers so the full discovery and invocation flow runs offline. Each handler exposes the two operations the client needs: `tools/list` and `tools/call`.
@@ -184,4 +184,4 @@ Tools, permissions, hooks, todo, task graph, memory, compact, background work, c
[s17 Integrated Harness](../s17_integrated_harness/) → Combine the mechanisms from s01-s16 into one harness. Many mechanisms, one loop.
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->
<!-- translation-sync: zh@v7, en@v7, ja@v7 -->

View File

@@ -33,11 +33,11 @@ MCPModel Context Protocol定义了 Agent 如何发现和调用外部工具
| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |
| mcp\_\_server\_\_tool 命名 | 避免不同 server 的工具名冲突 |
本章建立在 s15 团队运行时之上,沿用 idle 阶段的原子任务认领、安全的 task-worktree 绑定和协调协议,也保留 cron 调度、后台 bash 生命周期,以及任务完成后自动唤醒 Lead 的通知。新增的 `connect_mcp` 工具用于连接服务、发现工具并加入工具池。
本章建立在 s15 团队运行时之上,沿用 idle 阶段的原子任务认领、可在重启后恢复的 task-worktree 绑定,以及只对当前 assignment 生效的计划审批。后台 bash 会把非零退出报告为失败并在任务结束时停止命令原来的进程组durable 的一次性 cron 任务会先持久化为待投递,再进入队列,并一直保留到包含该 prompt 的模型调用成功。新增的 `connect_mcp` 工具用于连接服务、发现工具并加入工具池。
task-bound worktree 只会改变队友文件工具的默认工作目录,并不是安全沙箱。
模型可见的 `remove_worktree` 只接受 `name`,因此只能移除状态干净的 checkout。若确实要丢弃改动,应由用户手动执行 Git或者宿主在明确确认后调用底层的强制清理路径,不能让模型自行选择
Worktree 移除不对模型开放。用户或宿主先检查任务、assignment、后台进程和 Git 状态,再调用清理函数。丢弃改动仍是用户手动执行 Git 操作,或者宿主在明确确认后执行的操作
本章注册进程内 server handler让工具发现和调用流程可以离线运行。每个 handler 都提供客户端需要的 `tools/list``tools/call` 两个操作。
@@ -184,4 +184,4 @@ python s16_mcp_plugin/code.py
[s17 Agent Harness 集成](../s17_integrated_harness/) → 把 s01-s16 的机制合回同一个 harness。机制很多循环一个。
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->
<!-- translation-sync: zh@v7, en@v7, ja@v7 -->

View File

@@ -21,7 +21,8 @@ ASCII flow:
agent_loop uses assembled pool
"""
import os, subprocess, json, time, random, threading, queue, re
import atexit, fcntl, os, signal, subprocess, json, time, random, threading, queue, re
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict, field
@@ -49,10 +50,54 @@ TASKS_DIR = WORKDIR / ".tasks"
TASKS_DIR.mkdir(exist_ok=True)
TASKS_ROOT = TASKS_DIR.resolve()
task_lock = threading.RLock()
TASK_LOCK_PATH = TASKS_DIR / ".lock"
_task_store_state = threading.local()
# owner -> {"task_id": str, "cwd": Path}. A teammate gets one assignment at
# a time, and every filesystem tool resolves its cwd through this registry.
teammate_assignments: dict[str, dict[str, object]] = {}
assignment_versions: dict[str, int] = {}
@contextmanager
def task_store_lock():
"""Serialize task mutations across threads and host processes."""
with task_lock:
depth = getattr(_task_store_state, "depth", 0)
if depth == 0:
handle = TASK_LOCK_PATH.open("a+")
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
_task_store_state.handle = handle
_task_store_state.depth = depth + 1
try:
yield
finally:
_task_store_state.depth -= 1
if _task_store_state.depth == 0:
handle = _task_store_state.handle
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
del _task_store_state.handle
def advance_assignment_version(owner: str):
"""Invalidate old approvals without clearing an explicit plan requirement."""
with task_lock:
assignment_versions[owner] = assignment_versions.get(owner, 0) + 1
gates = globals().get("plan_gates")
request_ids = globals().get("plan_request_ids")
team = globals().get("team_lock")
if team is not None:
team.acquire()
try:
if (isinstance(gates, dict) and owner in gates
and gates[owner] != "not_required"):
gates[owner] = "required"
if isinstance(request_ids, dict):
request_ids.pop(owner, None)
finally:
if team is not None:
team.release()
@dataclass
@@ -91,17 +136,25 @@ def create_task(subject: str, description: str = "",
def save_task(task: Task):
with task_lock:
_task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
with task_store_lock():
path = _task_path(task.id)
temporary = path.with_name(
f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
temporary.write_text(json.dumps(asdict(task), indent=2))
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def load_task(task_id: str) -> Task:
with task_lock:
with task_store_lock():
return Task(**json.loads(_task_path(task_id).read_text()))
def list_tasks() -> list[Task]:
with task_lock:
with task_store_lock():
if not TASKS_ROOT.is_relative_to(WORKDIR.resolve()):
raise ValueError("Tasks directory escapes workspace")
return [load_task(path.stem)
@@ -149,12 +202,16 @@ def _incomplete_dependencies(task: Task) -> list[str]:
def claim_task(task_id: str, owner: str = "agent") -> str:
"""Atomically claim one task and bind the owner's filesystem cwd."""
with task_lock:
with task_store_lock():
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} is already owned by {task.owner}"
assignment = teammate_assignments.get(owner)
if assignment:
return (f"Owner {owner} must finish the current work turn for "
f"{assignment['task_id']} before claiming another task")
current = _owner_in_progress(owner)
if current:
return (f"Owner {owner} must complete {current.id} before "
@@ -168,24 +225,31 @@ def claim_task(task_id: str, owner: str = "agent") -> str:
task.status = "in_progress"
save_task(task)
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
advance_assignment_version(owner)
print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m")
return f"Claimed {task.id} ({task.subject})"
def complete_task(task_id: str, owner: str = "agent") -> str:
"""Complete an assignment only when the caller owns it."""
with task_lock:
with task_store_lock():
task = load_task(task_id)
if task.status != "in_progress":
return f"Task {task_id} is {task.status}, cannot complete"
if task.owner != owner:
return (f"Task {task_id} is owned by {task.owner}, "
f"not {owner}; cannot complete")
gate = globals().get("plan_gates", {}).get(owner, "not_required")
if gate in {"required", "pending", "rejected"}:
return f"Task {task_id} cannot complete while plan status is {gate}"
assignment = teammate_assignments.get(owner)
if not assignment or assignment.get("task_id") != task.id:
cwd, error = task_worktree_cwd(task)
if error:
return f"Task {task_id} cannot complete: {error}"
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
task.status = "completed"
save_task(task)
assignment = teammate_assignments.get(owner)
if assignment and assignment.get("task_id") == task_id:
teammate_assignments.pop(owner, None)
unblocked = [t.subject for t in list_tasks()
if t.status == "pending" and t.blockedBy and can_start(t.id)]
print(f" \033[32m[complete] {task.subject}\033[0m")
@@ -226,7 +290,7 @@ def _worktree_branch(name: str) -> str:
return f"wt/{name}"
def run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]:
def _run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]:
"""Run Git without shell interpolation and return (ok, combined output)."""
try:
result = subprocess.run(
@@ -236,11 +300,17 @@ def run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]:
except (OSError, subprocess.TimeoutExpired) as exc:
return False, f"{type(exc).__name__}: {exc}"
output = (result.stdout + result.stderr).strip()
return result.returncode == 0, output[:5000] or "(no output)"
return result.returncode == 0, output or "(no output)"
def run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]:
"""Run Git and bound only the text returned to the model."""
ok, output = _run_git(args, cwd)
return ok, output[:5000]
def _registered_worktrees() -> tuple[dict[Path, dict[str, str]], str | None]:
ok, output = run_git(["worktree", "list", "--porcelain"])
ok, output = _run_git(["worktree", "list", "--porcelain"])
if not ok:
return {}, f"cannot read Git worktree registry: {output}"
entries: dict[Path, dict[str, str]] = {}
@@ -287,12 +357,17 @@ def task_worktree_cwd(task: Task) -> tuple[Path, str | None]:
def assignment_cwd(owner: str) -> Path:
with task_lock:
assignment = teammate_assignments.get(owner)
if not assignment:
if _owner_in_progress(owner):
raise ValueError(f"Missing assignment metadata for {owner}")
task = _owner_in_progress(owner)
if task and (not assignment or assignment.get("task_id") != task.id):
cwd, error = task_worktree_cwd(task)
if error:
raise ValueError(error)
assignment = {"task_id": task.id, "cwd": cwd}
teammate_assignments[owner] = assignment
elif not assignment:
return WORKDIR
task = load_task(str(assignment["task_id"]))
if task.status != "in_progress" or task.owner != owner:
if task.status not in {"in_progress", "completed"} or task.owner != owner:
raise ValueError(f"Assignment for {owner} is no longer active")
cwd, error = task_worktree_cwd(task)
if error:
@@ -302,6 +377,22 @@ def assignment_cwd(owner: str) -> Path:
return cwd
def release_completed_assignment(owner: str) -> bool:
"""Release a completed cwd lease only at a model turn boundary."""
with task_lock:
assignment = teammate_assignments.get(owner)
if not assignment:
return False
task = load_task(str(assignment["task_id"]))
if task.status != "completed" or task.owner != owner:
return False
teammate_assignments.pop(owner, None)
advance_assignment_version(owner)
if owner in globals().get("plan_gates", {}):
globals()["plan_gates"][owner] = "not_required"
return True
def release_teammate_assignment(owner: str):
"""Return abandoned teammate work to the task board on thread exit."""
with task_lock:
@@ -313,6 +404,9 @@ def release_teammate_assignment(owner: str):
save_task(task)
finally:
teammate_assignments.pop(owner, None)
advance_assignment_version(owner)
if owner in globals().get("plan_gates", {}):
globals()["plan_gates"][owner] = "not_required"
def create_worktree(name: str, task_id: str) -> str:
@@ -410,6 +504,19 @@ def remove_worktree(name: str, discard_changes: bool = False) -> str:
if active:
return (f"Error: Worktree '{name}' is bound to active task "
f"{active[0].id}; complete it before removal")
leased = [owner for owner, assignment in teammate_assignments.items()
if Path(assignment["cwd"]).resolve() == path.resolve()]
if leased:
return (f"Error: Worktree '{name}' is still in use by "
f"{', '.join(sorted(leased))}; wait for the turn to end")
with globals().get("background_lock", threading.Lock()):
running = [task for task in globals().get("background_tasks", {}).values()
if task.get("status") == "running"
and task.get("cwd")
and Path(task["cwd"]).resolve() == path.resolve()]
if running:
return (f"Error: Worktree '{name}' has a running background command; "
"wait for it to finish")
ok, status = run_git(
["status", "--porcelain", "--ignored"], cwd=path
@@ -451,7 +558,7 @@ PROMPT_SECTIONS = {
"schedule_cron, list_crons, cancel_cron, "
"spawn_teammate, send_message, "
"request_shutdown, request_plan, review_plan, "
"create_worktree, remove_worktree, "
"create_worktree, "
"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.",
"teams": (
"When parallel work would help, first propose a small team with clear "
@@ -461,8 +568,8 @@ PROMPT_SECTIONS = {
"create a task-bound worktree only when a separate working directory "
"would prevent conflicting edits. A teammate must complete its current "
"Task before claiming another. A worktree changes tool default cwd "
"only; it is not a sandbox. The remove_worktree tool removes only "
"clean checkouts and never discards changes. React to team events "
"only; it is not a sandbox. Worktree removal stays with the host or "
"user. React to team events "
"delivered by the runtime, and shut teammates down when coordination "
"is complete."
),
@@ -494,18 +601,78 @@ def safe_path(p: str, cwd: Path | None = None) -> Path:
return path
_shell_processes: set[subprocess.Popen] = set()
_shell_process_lock = threading.RLock()
def _stop_process_group(process: subprocess.Popen):
"""Stop processes that remain in the command's original process group."""
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(process.pid, sig)
except ProcessLookupError:
return
except OSError:
return
time.sleep(0.05)
def _stop_all_shell_processes():
with _shell_process_lock:
processes = list(_shell_processes)
for process in processes:
_stop_process_group(process)
def _handle_termination_signal(signum, _frame):
_stop_all_shell_processes()
raise SystemExit(128 + signum)
atexit.register(_stop_all_shell_processes)
signal.signal(signal.SIGTERM, _handle_termination_signal)
def _run_bash_process(command: str, cwd: Path | None = None) -> tuple[str, int | None]:
process = None
try:
process = subprocess.Popen(
command, shell=True, cwd=cwd or WORKDIR,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, start_new_session=True,
)
with _shell_process_lock:
_shell_processes.add(process)
stdout, stderr = process.communicate(timeout=120)
out = (stdout + stderr).strip()
return (out[:50000] if out else "(no output)"), process.returncode
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)", None
except OSError as exc:
return f"Error: {type(exc).__name__}: {exc}", None
finally:
if process is not None:
_stop_process_group(process)
try:
process.wait(timeout=0.2)
except subprocess.TimeoutExpired:
pass
with _shell_process_lock:
_shell_processes.discard(process)
def _format_bash_result(output: str, exit_code: int | None) -> str:
if exit_code == 0:
return output
if exit_code is None:
return output
return f"Error: command exited with status {exit_code}\n{output}"
def run_bash(command: str, run_in_background: bool = False,
cwd: Path | None = None) -> str:
# run_in_background is handled by agent_loop dispatch, not here
try:
r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except OSError as exc:
return f"Error: {type(exc).__name__}: {exc}"
return _format_bash_result(*_run_bash_process(command, cwd))
def run_read(path: str, limit: int | None = None,
@@ -530,6 +697,28 @@ def run_write(path: str, content: str,
return f"Error: {e}"
def _agent_cwd() -> tuple[Path | None, str | None]:
try:
return assignment_cwd("agent"), None
except (FileNotFoundError, ValueError) as exc:
return None, f"Error: Invalid task assignment: {exc}"
def run_agent_bash(command: str, run_in_background: bool = False) -> str:
cwd, error = _agent_cwd()
return error or run_bash(command, run_in_background, cwd)
def run_agent_read(path: str, limit: int | None = None) -> str:
cwd, error = _agent_cwd()
return error or run_read(path, limit, cwd)
def run_agent_write(path: str, content: str) -> str:
cwd, error = _agent_cwd()
return error or run_write(path, content, cwd)
# ── Background Tasks (from s13, synced) ──
_bg_counter = 0
@@ -551,30 +740,45 @@ def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
return True
return is_slow_operation(tool_name, tool_input)
return tool_name == "bash" and (
tool_input.get("run_in_background") is True
or is_slow_operation(tool_name, tool_input)
)
def execute_tool(block, handlers: dict) -> str:
"""Execute one call against the current dynamic tool pool."""
handler = handlers.get(block.name)
if handler:
if not handler:
return f"Unknown tool: {block.name}"
try:
return str(handler(**block.input))
return f"Unknown tool: {block.name}"
except (TypeError, ValueError) as exc:
return f"Error: {exc}"
def start_background_task(block, handlers: dict) -> str:
"""Run a tool in a daemon thread and return its background task ID."""
"""Run one bash call in a daemon thread with a fixed dispatch cwd."""
global _bg_counter
_bg_counter += 1
bg_id = f"bg_{_bg_counter:04d}"
cmd = block.input.get("command", block.name)
cwd, cwd_error = _agent_cwd()
def worker():
result = execute_tool(block, handlers)
try:
if block.name != "bash":
raise ValueError("only bash can run in the background")
if cwd_error:
raise ValueError(cwd_error.removeprefix("Error: "))
output, exit_code = _run_bash_process(str(block.input["command"]), cwd)
result = _format_bash_result(output, exit_code)
status = "completed" if exit_code == 0 else "failed"
except Exception as exc:
result = f"Error: {type(exc).__name__}: {exc}"
status = "failed"
with background_lock:
background_tasks[bg_id]["status"] = "completed"
background_tasks[bg_id]["status"] = status
background_results[bg_id] = result
with background_lock:
@@ -582,6 +786,7 @@ def start_background_task(block, handlers: dict) -> str:
"tool_use_id": block.id,
"command": cmd,
"status": "running",
"cwd": str(cwd) if cwd else None,
}
threading.Thread(target=worker, daemon=True).start()
print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m")
@@ -589,10 +794,10 @@ def start_background_task(block, handlers: dict) -> str:
def collect_background_results() -> list[str]:
"""Collect completed results as task_notification messages."""
"""Collect terminal results as task_notification messages."""
with background_lock:
ready_ids = [bid for bid, task in background_tasks.items()
if task["status"] == "completed"]
if task["status"] in {"completed", "failed"}]
notifications = []
for bg_id in ready_ids:
with background_lock:
@@ -602,7 +807,7 @@ def collect_background_results() -> list[str]:
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <status>{task['status']}</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{summary}</summary>\n"
f"</task_notification>")
@@ -612,9 +817,10 @@ def collect_background_results() -> list[str]:
def has_pending_background() -> bool:
"""Return whether a completed background result is waiting for Lead."""
"""Return whether a terminal background result is waiting for Lead."""
with background_lock:
return any(t["status"] == "completed" for t in background_tasks.values())
return any(t["status"] in {"completed", "failed"}
for t in background_tasks.values())
# ── Cron Scheduler (from s14, synced) ──
@@ -629,11 +835,12 @@ class CronJob:
prompt: str
recurring: bool
durable: bool
pending_delivery: bool = False
scheduled_jobs: dict[str, CronJob] = {}
cron_queue: list[CronJob] = []
cron_lock = threading.Lock()
cron_lock = threading.RLock()
_last_fired: dict[str, str] = {}
@@ -727,8 +934,11 @@ def validate_cron(cron_expr: str) -> str | None:
def save_durable_jobs():
durable = [asdict(job) for job in scheduled_jobs.values() if job.durable]
DURABLE_PATH.write_text(json.dumps(durable, indent=2))
with cron_lock:
durable = [asdict(job) for job in scheduled_jobs.values() if job.durable]
temporary = DURABLE_PATH.with_suffix(".json.tmp")
temporary.write_text(json.dumps(durable, indent=2))
os.replace(temporary, DURABLE_PATH)
def load_durable_jobs():
@@ -743,6 +953,8 @@ def load_durable_jobs():
print(f" \033[31m[cron] skipping invalid job {job.id}: {error}\033[0m")
continue
scheduled_jobs[job.id] = job
if job.pending_delivery:
cron_queue.append(job)
valid = [item for item in jobs if item["id"] in scheduled_jobs]
if valid:
print(f" \033[35m[cron] loaded {len(valid)} durable job(s)\033[0m")
@@ -764,8 +976,8 @@ def schedule_job(cron: str, prompt: str, recurring: bool = True,
)
with cron_lock:
scheduled_jobs[job.id] = job
if durable:
save_durable_jobs()
if durable:
save_durable_jobs()
print(f" \033[35m[cron register] {job.id} '{cron}'{prompt[:40]}\033[0m")
return job
@@ -773,14 +985,28 @@ def schedule_job(cron: str, prompt: str, recurring: bool = True,
def cancel_job(job_id: str) -> str:
with cron_lock:
job = scheduled_jobs.pop(job_id, None)
cron_queue[:] = [queued for queued in cron_queue if queued.id != job_id]
if job and job.durable:
save_durable_jobs()
if not job:
return f"Job {job_id} not found"
if job.durable:
save_durable_jobs()
print(f" \033[31m[cron cancel] {job_id}\033[0m")
return f"Cancelled {job_id}"
def _enqueue_due_job(job: CronJob):
"""Persist a one-shot delivery before exposing it through the queue."""
if not job.recurring:
job.pending_delivery = True
try:
if job.durable:
save_durable_jobs()
except Exception:
job.pending_delivery = False
raise
cron_queue.append(job)
def cron_scheduler_loop():
while True:
time.sleep(1)
@@ -789,16 +1015,14 @@ def cron_scheduler_loop():
with cron_lock:
for job in list(scheduled_jobs.values()):
try:
if job.pending_delivery:
continue
if cron_matches(job.cron, now):
if _last_fired.get(job.id) != minute_marker:
cron_queue.append(job)
_enqueue_due_job(job)
_last_fired[job.id] = minute_marker
print(f" \033[35m[cron fire] {job.id}"
f"{job.prompt[:40]}\033[0m")
if not job.recurring:
scheduled_jobs.pop(job.id, None)
if job.durable:
save_durable_jobs()
except Exception as exc:
print(f" \033[31m[cron error] {job.id}: {exc}\033[0m")
@@ -810,6 +1034,35 @@ def consume_cron_queue() -> list[CronJob]:
return fired
def has_cron_queue() -> bool:
with cron_lock:
return bool(cron_queue)
def acknowledge_cron_jobs(jobs: list[CronJob]):
"""Remove one-shot jobs after a model call accepts their prompts."""
durable_changed = False
with cron_lock:
for job in jobs:
current = scheduled_jobs.get(job.id)
if current and not current.recurring and current.pending_delivery:
scheduled_jobs.pop(job.id, None)
durable_changed = durable_changed or current.durable
if durable_changed:
save_durable_jobs()
def restore_cron_jobs(jobs: list[CronJob]):
"""Put unacknowledged deliveries back after a failed model call."""
with cron_lock:
queued_ids = {job.id for job in cron_queue}
for job in jobs:
current = scheduled_jobs.get(job.id)
if current and current.id not in queued_ids:
cron_queue.append(current)
queued_ids.add(current.id)
load_durable_jobs()
threading.Thread(target=cron_scheduler_loop, daemon=True).start()
print(" \033[35m[cron] scheduler thread started\033[0m")
@@ -926,6 +1179,8 @@ class ProtocolState:
target: str
status: str
payload: str
work_version: int | None = None
task_id: str | None = None
created_at: float = field(default_factory=time.time)
@@ -1013,7 +1268,7 @@ def scan_unclaimed_tasks() -> list[Task]:
def claim_next_task(name: str) -> Task | None:
"""Claim the first still-available task, never a second assignment."""
with task_lock:
if _owner_in_progress(name):
if teammate_assignments.get(name) or _owner_in_progress(name):
return None
for task in scan_unclaimed_tasks():
result = claim_task(task.id, owner=name)
@@ -1031,6 +1286,13 @@ def _last_assistant_text(content) -> str:
return ""
def current_work_identity(owner: str) -> tuple[int, str | None]:
with task_lock:
assignment = teammate_assignments.get(owner)
task_id = str(assignment["task_id"]) if assignment else None
return assignment_versions.get(owner, 0), task_id
def _run_teammate_tool(name: str, block, handlers: dict) -> str:
gate = plan_gates.get(name, "not_required")
if (block.name in {"bash", "write_file"}
@@ -1044,6 +1306,7 @@ def apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:
"""Apply only the Lead response for this teammate's current plan."""
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
work_version, task_id = current_work_identity(name)
with team_lock:
state = pending_requests.get(request_id)
expected_id = plan_request_ids.get(name)
@@ -1055,6 +1318,8 @@ def apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:
and state.type == "plan_approval"
and state.sender == name
and state.target == "lead"
and state.work_version == work_version
and state.task_id == task_id
and state.status in {"approved", "rejected"}
and metadata.get("approve", False)
== (state.status == "approved")
@@ -1099,7 +1364,8 @@ def _teammate_send_message(from_name: str, to: str, content: str) -> str:
# ── Teammate Thread ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
def spawn_teammate_thread(name: str, role: str, prompt: str,
require_plan: bool = False) -> str:
if not is_valid_agent_name(name):
return ("Invalid teammate name: use 1-64 letters, digits, "
"underscores, or dashes")
@@ -1110,7 +1376,8 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
for existing in active_teammates):
return f"Teammate '{name}' already exists"
active_teammates[name] = "working"
plan_gates[name] = "not_required"
plan_gates[name] = "required" if require_plan else "not_required"
assignment_versions[name] = 1
system = (f"You are '{name}', a {role}. "
"Use tools to complete assigned work. You can list, claim, and "
@@ -1195,7 +1462,11 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
except FileNotFoundError:
return f"Error: Task {task_id} not found"
messages = [{"role": "user", "content": prompt}]
initial_prompt = prompt
if require_plan:
initial_prompt += ("\n\n[Plan required] Submit a plan and wait for "
"Lead approval before bash or write_file.")
messages = [{"role": "user", "content": initial_prompt}]
sub_tools = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
@@ -1250,6 +1521,12 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
should_stop = False
while not should_stop:
for msg in BUS.read_inbox(name):
if handle_inbox_message(name, msg, messages):
should_stop = True
break
if should_stop:
break
with team_lock:
active_teammates[name] = "working"
try:
@@ -1281,6 +1558,7 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
with team_lock:
active_teammates[name] = "waiting_approval"
else:
release_completed_assignment(name)
with team_lock:
active_teammates[name] = "idle"
BUS.send(name, "lead", "Waiting for more work.",
@@ -1348,17 +1626,22 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
def _teammate_submit_plan(from_name: str, plan: str) -> str:
with team_lock:
if plan_gates.get(from_name) == "pending":
return "A plan is already waiting for review."
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="plan_approval",
sender=from_name, target="lead",
status="pending", payload=plan)
plan_gates[from_name] = "pending"
plan_request_ids[from_name] = req_id
active_teammates[from_name] = "waiting_approval"
with task_lock:
assignment = teammate_assignments.get(from_name)
task_id = str(assignment["task_id"]) if assignment else None
work_version = assignment_versions.get(from_name, 0)
with team_lock:
if plan_gates.get(from_name) == "pending":
return "A plan is already waiting for review."
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="plan_approval",
sender=from_name, target="lead",
status="pending", payload=plan,
work_version=work_version, task_id=task_id)
plan_gates[from_name] = "pending"
plan_request_ids[from_name] = req_id
active_teammates[from_name] = "waiting_approval"
BUS.send(from_name, "lead", plan,
"plan_approval_request",
{"request_id": req_id})
@@ -1395,6 +1678,10 @@ def run_request_plan(teammate: str, task: str) -> str:
def run_review_plan(request_id: str, approve: bool,
feedback: str = "") -> str:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
work_version, task_id = current_work_identity(state.sender)
with team_lock:
state = pending_requests.get(request_id)
if not state:
@@ -1403,6 +1690,8 @@ def run_review_plan(request_id: str, approve: bool,
return f"Request {request_id} is not a plan"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
if state.work_version != work_version or state.task_id != task_id:
return f"Request {request_id} belongs to an earlier assignment"
if plan_request_ids.get(state.sender) != request_id:
return f"Request {request_id} is not the current plan"
state.status = "approved" if approve else "rejected"
@@ -1544,10 +1833,6 @@ def assemble_tool_pool() -> tuple[list[dict], dict]:
def run_create_worktree(name: str, task_id: str) -> str:
return create_worktree(name, task_id)
def run_remove_worktree(name: str) -> str:
"""Model-facing cleanup never opts into destructive removal."""
return remove_worktree(name)
# ── Basic tool handlers ──
def run_create_task(subject: str, description: str = "",
@@ -1592,12 +1877,14 @@ def run_complete_task(task_id: str) -> str:
except FileNotFoundError:
return f"Error: Task {task_id} not found"
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
return spawn_teammate_thread(name, role, prompt)
def run_spawn_teammate(name: str, role: str, prompt: str,
require_plan: bool = False) -> str:
return spawn_teammate_thread(name, role, prompt, require_plan)
def run_send_message(to: str, content: str) -> str:
if to not in active_teammates:
return f"Teammate '{to}' is not active"
advance_assignment_version(to)
BUS.send("lead", to, content)
return f"Sent to {to}"
@@ -1674,7 +1961,8 @@ BUILTIN_TOOLS = [
"pattern": "^[A-Za-z0-9_-]{1,64}$",
},
"role": {"type": "string"},
"prompt": {"type": "string"}},
"prompt": {"type": "string"},
"require_plan": {"type": "boolean"}},
"required": ["name", "role", "prompt"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object",
@@ -1711,17 +1999,6 @@ BUILTIN_TOOLS = [
"task_id": {"type": "string"}},
"required": ["name", "task_id"],
"additionalProperties": False}},
{"name": "remove_worktree",
"description": "Remove a clean task worktree while retaining its branch.",
"input_schema": {"type": "object",
"properties": {"name": {
"type": "string",
"pattern": ("^(?!.*\\.\\.)[A-Za-z0-9]"
"[A-Za-z0-9._-]{0,63}$"),
"maxLength": 64,
}},
"required": ["name"],
"additionalProperties": False}},
{"name": "connect_mcp",
"description": "Connect to an MCP server (docs, deploy) and discover tools.",
"input_schema": {"type": "object",
@@ -1730,7 +2007,9 @@ BUILTIN_TOOLS = [
]
BUILTIN_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"bash": run_agent_bash,
"read_file": run_agent_read,
"write_file": run_agent_write,
"create_task": run_create_task, "list_tasks": run_list_tasks,
"get_task": run_get_task,
"claim_task": run_claim_task, "complete_task": run_complete_task,
@@ -1741,7 +2020,6 @@ BUILTIN_HANDLERS = {
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan, "review_plan": run_review_plan,
"create_worktree": run_create_worktree,
"remove_worktree": run_remove_worktree,
"connect_mcp": run_connect_mcp,
}
@@ -1765,22 +2043,27 @@ def agent_loop(messages: list, context: dict):
tools, handlers = assemble_tool_pool()
system = assemble_system_prompt(context)
while True:
for job in consume_cron_queue():
fired = consume_cron_queue()
for job in fired:
messages.append({"role": "user",
"content": f"[Scheduled] {job.prompt}"})
print(f" \033[35m[inject cron] {job.prompt[:50]}\033[0m")
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=tools, max_tokens=8000)
except Exception as e:
restore_cron_jobs(fired)
messages.append({"role": "assistant", "content": [
{"type": "text", "text": f"[Error] {type(e).__name__}: {e}"}]})
release_completed_assignment("agent")
return
acknowledge_cron_jobs(fired)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
release_completed_assignment("agent")
return
results = []
@@ -1834,7 +2117,8 @@ if __name__ == "__main__":
def inbox_poller():
while True:
time.sleep(1)
if BUS.peek("lead") or has_pending_background():
if (BUS.peek("lead") or has_pending_background()
or has_cron_queue()):
events.put(("wake", None))
threading.Thread(target=input_reader, daemon=True).start()
@@ -1851,18 +2135,19 @@ if __name__ == "__main__":
history.append({"role": "user", "content": payload})
else:
parts = []
cron_ready = has_cron_queue()
inbox = consume_lead_inbox(route_protocol=True)
if inbox:
parts.append(format_team_events(inbox))
background = collect_background_results()
parts.extend(background)
if not parts:
if not parts and not cron_ready:
continue
history.append({"role": "user",
"content": "\n".join(parts)})
print(f"\n\033[33m[wake: {len(inbox)} team events + "
f"{len(background)} background "
f"-> new turn]\033[0m")
f"{1 if cron_ready else 0} cron -> new turn]\033[0m")
agent_loop(history, context)
context = update_context(context, history)

View File

@@ -52,7 +52,7 @@
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (Lead 16 tools)</text>
<text x="394" y="109" fill="#2563eb" font-size="7.5">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="121" fill="#7c3aed" font-size="7.5" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · host cleanup</text>
<text x="394" y="145" fill="#dc2626" font-size="7.5" font-weight="700">★ connect_mcp + dynamic mcp__server__tool tools</text>
<!-- Loop back -->

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -52,7 +52,7 @@
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCHLead 16 tools</text>
<text x="394" y="109" fill="#2563eb" font-size="7.5">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="121" fill="#7c3aed" font-size="7.5" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · host cleanup</text>
<text x="394" y="145" fill="#dc2626" font-size="7.5" font-weight="700">★ connect_mcp + 動的 mcp__server__tool ツール</text>
<!-- Loop back -->

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -52,7 +52,7 @@
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (Lead 16 tools)</text>
<text x="394" y="109" fill="#2563eb" font-size="7.5">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="121" fill="#7c3aed" font-size="7.5" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · host cleanup</text>
<text x="394" y="145" fill="#dc2626" font-size="7.5" font-weight="700">★ connect_mcp + 动态 mcp__server__tool 工具</text>
<!-- Loop back -->

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB