feat: implement core/team_runner.py and cli/agency.py
- core/blackboard.py: add t3_task_lists table, extend event kinds to
include full visibility vocabulary (gate_pending, gate_approved,
gate_rejected, gate_paused, gate_resumed, path_amendment, log), and
add query methods (get_events, get_latest_gate_event, get_t3_task_lists,
all_t3_committed, get_briefs, get_workstreams, etc.)
- core/team_runner.py: full run lifecycle orchestrator
- Loads team.yaml + role_registry.yaml, instantiates all four adapter types
- T1 Plan (two-phase: plan + accept), T2 Lead/Specialist/Synthesis,
T3 Squad Lead with mesh draft/commit cycle and mesh-timeout escalation,
T4 swarm+pipeline with dep-ordering, T5 fan-out + joint verdict
- Async (asyncio.gather) for parallel workstream and T4/T5 fan-out
- Gate logic: gate_pending → notify → poll blackboard → gate_approved/rejected
- Path amendment monitor (background task)
- EscalationHandler integrated into _dispatch_with_retry
- T3 mesh timeout → T2 re-scope escalation
- Terminal failure → notify + run status=failed
- VCS branch creation + PR at T1 Accept phase
- Runtime selection: coding_agent runtime for preferred_runtime="coding_agent",
tier_runtime_map overrides, fallback to default runtime
- cli/agency.py: agency CLI with subcommands
- run: start pipeline, prints run_id + watch/inspect hints
- watch: tails blackboard events live with ANSI-coloured output
- inspect: run tree, --tier filter, --brief detail view
- approve: write gate_approved directly to blackboard (universal gate path)
- reject: write gate_rejected with --reason
- pause: write gate_paused signal
- resume: write gate_resumed signal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -85,18 +85,37 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
brief_id TEXT, -- NULL for run-level events
|
||||
kind TEXT NOT NULL, -- spawned|completed|failed|escalated|retried
|
||||
kind TEXT NOT NULL, -- see _EVENT_KINDS
|
||||
detail TEXT, -- JSON
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (run_id) REFERENCES runs(run_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t3_task_lists (
|
||||
entry_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
workstream_id TEXT NOT NULL,
|
||||
t3_agent_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft', -- draft|committed
|
||||
tasks TEXT NOT NULL DEFAULT '[]', -- JSON array of T4 task descriptors
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (run_id) REFERENCES runs(run_id)
|
||||
);
|
||||
"""
|
||||
|
||||
# Valid status values per table — used for input validation.
|
||||
_RUN_STATUSES = {"pending", "active", "review", "done", "failed"}
|
||||
_WS_STATUSES = {"pending", "active", "blocked", "done", "failed"}
|
||||
_BRIEF_STATUSES = {"pending", "active", "done", "failed"}
|
||||
_EVENT_KINDS = {"spawned", "completed", "failed", "escalated", "retried"}
|
||||
_EVENT_KINDS = {
|
||||
# Lifecycle
|
||||
"spawned", "completed", "failed", "escalated", "retried",
|
||||
# Visibility / gates
|
||||
"gate_pending", "gate_approved", "gate_rejected", "gate_paused", "gate_resumed",
|
||||
# Amendments / informational
|
||||
"path_amendment", "log",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -360,6 +379,194 @@ class Blackboard:
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_events(
|
||||
self,
|
||||
kinds: Optional[list[str]] = None,
|
||||
after_iso: Optional[str] = None,
|
||||
brief_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query events for this run.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
kinds : Filter by event kinds (OR). None = all kinds.
|
||||
after_iso : Only return events created after this ISO-8601 timestamp.
|
||||
brief_id : Filter by brief_id. None = all briefs.
|
||||
limit : Maximum rows to return (most recent first).
|
||||
"""
|
||||
conditions = ["run_id = ?"]
|
||||
params: list[Any] = [self.run_id]
|
||||
|
||||
if kinds:
|
||||
placeholders = ",".join("?" * len(kinds))
|
||||
conditions.append(f"kind IN ({placeholders})")
|
||||
params.extend(kinds)
|
||||
|
||||
if after_iso:
|
||||
conditions.append("created_at > ?")
|
||||
params.append(after_iso)
|
||||
|
||||
if brief_id:
|
||||
conditions.append("brief_id = ?")
|
||||
params.append(brief_id)
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
rows = self._execute(
|
||||
f"SELECT * FROM events WHERE {where} ORDER BY created_at DESC LIMIT ?",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_latest_gate_event(
|
||||
self, gate_name: str, after_iso: Optional[str] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Return the most recent gate_approved or gate_rejected event for
|
||||
*gate_name* written after *after_iso*.
|
||||
|
||||
The event detail JSON is expected to contain a ``"gate"`` field
|
||||
matching *gate_name*. Falls back to returning any gate resolution
|
||||
event if none carry an explicit gate field (for CLI-written events
|
||||
that omit it).
|
||||
"""
|
||||
events = self.get_events(
|
||||
kinds=["gate_approved", "gate_rejected"],
|
||||
after_iso=after_iso,
|
||||
limit=20,
|
||||
)
|
||||
# Prefer events whose detail.gate matches
|
||||
for ev in events:
|
||||
try:
|
||||
detail = json.loads(ev.get("detail") or "{}")
|
||||
if detail.get("gate") == gate_name or not detail.get("gate"):
|
||||
return ev
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ev
|
||||
return None
|
||||
|
||||
def get_all_events(self, limit: int = 500) -> list[dict[str, Any]]:
|
||||
"""Return all events for this run, oldest first."""
|
||||
rows = self._execute(
|
||||
"SELECT * FROM events WHERE run_id=? ORDER BY created_at ASC LIMIT ?",
|
||||
(self.run_id, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T3 task lists
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_t3_draft(
|
||||
self,
|
||||
*,
|
||||
workstream_id: str,
|
||||
t3_agent_id: str,
|
||||
) -> str:
|
||||
"""Insert a draft t3_task_list entry. Returns entry_id."""
|
||||
entry_id = _new_uuid()
|
||||
now = _now_iso()
|
||||
self._execute(
|
||||
"INSERT OR IGNORE INTO t3_task_lists "
|
||||
"(entry_id, run_id, workstream_id, t3_agent_id, status, tasks, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'draft', '[]', ?, ?)",
|
||||
(entry_id, self.run_id, workstream_id, t3_agent_id, now, now),
|
||||
commit=True,
|
||||
)
|
||||
return entry_id
|
||||
|
||||
def commit_t3_task_list(
|
||||
self,
|
||||
*,
|
||||
workstream_id: str,
|
||||
t3_agent_id: str,
|
||||
tasks: list[Any],
|
||||
) -> None:
|
||||
"""Update a t3_task_list entry to committed with the final task list."""
|
||||
now = _now_iso()
|
||||
tasks_json = json.dumps(tasks)
|
||||
self._execute(
|
||||
"UPDATE t3_task_lists SET status='committed', tasks=?, updated_at=? "
|
||||
"WHERE run_id=? AND workstream_id=? AND t3_agent_id=?",
|
||||
(tasks_json, now, self.run_id, workstream_id, t3_agent_id),
|
||||
commit=True,
|
||||
)
|
||||
|
||||
def get_t3_task_lists(self, workstream_id: str) -> list[dict[str, Any]]:
|
||||
"""Return all t3_task_list entries for a workstream."""
|
||||
rows = self._execute(
|
||||
"SELECT * FROM t3_task_lists WHERE run_id=? AND workstream_id=? ORDER BY created_at ASC",
|
||||
(self.run_id, workstream_id),
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["tasks"] = json.loads(d.get("tasks") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d["tasks"] = []
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
def all_t3_committed(self, workstream_id: str) -> bool:
|
||||
"""Return True if all t3_task_list entries for the workstream are committed."""
|
||||
rows = self._execute(
|
||||
"SELECT status FROM t3_task_lists WHERE run_id=? AND workstream_id=?",
|
||||
(self.run_id, workstream_id),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return False
|
||||
return all(r["status"] == "committed" for r in rows)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Briefs query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_briefs(
|
||||
self,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
tier: Optional[int] = None,
|
||||
workstream_id: Optional[str] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query briefs with optional filters."""
|
||||
conditions = ["run_id = ?"]
|
||||
params: list[Any] = [self.run_id]
|
||||
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
params.append(status)
|
||||
if tier is not None:
|
||||
conditions.append("tier = ?")
|
||||
params.append(tier)
|
||||
if workstream_id:
|
||||
conditions.append("workstream_id = ?")
|
||||
params.append(workstream_id)
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
rows = self._execute(
|
||||
f"SELECT * FROM briefs WHERE {where} ORDER BY created_at ASC",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_workstreams(self) -> list[dict[str, Any]]:
|
||||
"""Return all workstreams for this run."""
|
||||
rows = self._execute(
|
||||
"SELECT * FROM workstreams WHERE run_id=? ORDER BY created_at ASC",
|
||||
(self.run_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection gracefully."""
|
||||
with self._lock:
|
||||
|
||||
1668
core/team_runner.py
1668
core/team_runner.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user