caudate-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- voice/tts.py +214 -0
planning/orchestrator.py
ADDED
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
"""Forge orchestrator — pick the next ready feature, run an agent on it.
|
|
2
|
+
|
|
3
|
+
Python port of LocalForge's lib/agent/orchestrator.ts (1099 lines TS),
|
|
4
|
+
adapted to Cognos's idioms:
|
|
5
|
+
- In-process asyncio instead of detached child processes (LocalForge
|
|
6
|
+
uses Node's spawn(); we keep Caudate observation, permissions,
|
|
7
|
+
hooks, and LLM connection pooling shared with the parent).
|
|
8
|
+
- SQLAlchemy state instead of Drizzle (planning/forge_models.py).
|
|
9
|
+
- asyncio.Event broadcasting instead of EventEmitter (subscribers get
|
|
10
|
+
an async generator they can iterate over for SSE).
|
|
11
|
+
|
|
12
|
+
Lifecycle (per session):
|
|
13
|
+
start_orchestrator(project_id)
|
|
14
|
+
→ pick highest-priority ready feature whose deps are completed
|
|
15
|
+
→ flip it to in_progress, create forge_sessions row
|
|
16
|
+
→ spawn an asyncio task running CognosAgent on the feature task
|
|
17
|
+
→ emit log+status events as work progresses
|
|
18
|
+
finalize_session(session_id, outcome)
|
|
19
|
+
→ success: feature → completed; auto-continue with next ready
|
|
20
|
+
→ failure: feature → backlog with priority demoted (-1)
|
|
21
|
+
→ terminated: feature → backlog (no demotion, user stopped manually)
|
|
22
|
+
|
|
23
|
+
Concurrency:
|
|
24
|
+
Per-project max set by `forge.max_concurrent` setting (default 3).
|
|
25
|
+
start_orchestrator returns 409 if the cap is hit. Auto-continue fills
|
|
26
|
+
empty slots after each finalize.
|
|
27
|
+
|
|
28
|
+
Watchdog:
|
|
29
|
+
Each session has a deadline (`forge.session_timeout_s` setting,
|
|
30
|
+
default 30 min). When it fires, the asyncio task is cancelled and
|
|
31
|
+
the session finalises as `terminated`.
|
|
32
|
+
|
|
33
|
+
Reconciliation:
|
|
34
|
+
On startup, any sessions whose row says in_progress but isn't in
|
|
35
|
+
the running map are reaped to `terminated`, and their features
|
|
36
|
+
reset to backlog. This handles cognos-serve restarts.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import asyncio
|
|
42
|
+
import logging
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any, AsyncIterator, Callable
|
|
46
|
+
|
|
47
|
+
from planning.forge_models import (
|
|
48
|
+
ForgeFeature,
|
|
49
|
+
ForgeFeatureDep,
|
|
50
|
+
ForgeLog,
|
|
51
|
+
ForgeProject,
|
|
52
|
+
ForgeSession,
|
|
53
|
+
ForgeSetting,
|
|
54
|
+
init,
|
|
55
|
+
session_scope,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ───────────────────────── Configuration knobs ────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_forge_setting(key: str, default: str) -> str:
|
|
65
|
+
"""Read a forge setting from the existing core/settings.py hierarchy.
|
|
66
|
+
Settings live under the `forge.<key>` namespace so they don't
|
|
67
|
+
collide with top-level cognos settings."""
|
|
68
|
+
try:
|
|
69
|
+
from core.settings import Settings
|
|
70
|
+
s = Settings.load()
|
|
71
|
+
forge_block = s.get("forge") or {}
|
|
72
|
+
return str(forge_block.get(key, default))
|
|
73
|
+
except Exception:
|
|
74
|
+
return default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# A feature that fails this many sessions in a row is parked in
|
|
78
|
+
# `status="failed"` rather than bouncing back to the backlog. Stops
|
|
79
|
+
# the auto-continue loop from hammering the same broken feature
|
|
80
|
+
# forever. The user can manually flip status back to "backlog" once
|
|
81
|
+
# the underlying cause is fixed.
|
|
82
|
+
_MAX_FEATURE_FAILURES = 3
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _max_concurrent_for_project(project_id: int) -> int:
|
|
86
|
+
"""Hard cap is 6. Priority chain:
|
|
87
|
+
1. per-project override (forge_settings.max_concurrent_agents)
|
|
88
|
+
2. global cognos forge.max_concurrent
|
|
89
|
+
3. default 3
|
|
90
|
+
"""
|
|
91
|
+
# Per-project override
|
|
92
|
+
try:
|
|
93
|
+
with session_scope() as sess:
|
|
94
|
+
row = (
|
|
95
|
+
sess.query(ForgeSetting)
|
|
96
|
+
.filter_by(project_id=project_id, key="max_concurrent_agents")
|
|
97
|
+
.first()
|
|
98
|
+
)
|
|
99
|
+
if row and row.value:
|
|
100
|
+
try:
|
|
101
|
+
return max(1, min(6, int(row.value)))
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
raw = _get_forge_setting("max_concurrent", "3")
|
|
107
|
+
try:
|
|
108
|
+
n = int(raw)
|
|
109
|
+
except ValueError:
|
|
110
|
+
n = 3
|
|
111
|
+
return max(1, min(6, n))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _session_timeout_s() -> int:
|
|
115
|
+
raw = _get_forge_setting("session_timeout_s", str(30 * 60))
|
|
116
|
+
try:
|
|
117
|
+
return int(raw)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return 30 * 60
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ───────────────────────────── Errors ────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class OrchestratorError(Exception):
|
|
126
|
+
"""Carries an HTTP-style status so api/server.py can translate."""
|
|
127
|
+
def __init__(self, message: str, status: int = 500):
|
|
128
|
+
super().__init__(message)
|
|
129
|
+
self.status = status
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ─────────────────────── In-memory running state ──────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _RunningSession:
|
|
136
|
+
"""Live state for one in-flight session. Persisted state is in DB."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, session_id: int, project_id: int, feature_id: int,
|
|
139
|
+
task: asyncio.Task[Any], started_at: float):
|
|
140
|
+
self.session_id = session_id
|
|
141
|
+
self.project_id = project_id
|
|
142
|
+
self.feature_id = feature_id
|
|
143
|
+
self.task = task
|
|
144
|
+
self.started_at = started_at
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _OrchestratorState:
|
|
148
|
+
"""Process-wide singleton holding live sessions + subscribers."""
|
|
149
|
+
|
|
150
|
+
def __init__(self):
|
|
151
|
+
self.running: dict[int, _RunningSession] = {} # session_id -> rs
|
|
152
|
+
# session_id -> list of asyncio.Queue (one per SSE subscriber)
|
|
153
|
+
self.subscribers: dict[int, list[asyncio.Queue]] = {}
|
|
154
|
+
# listeners that get every event regardless of session
|
|
155
|
+
self.global_subscribers: list[asyncio.Queue] = []
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_STATE: _OrchestratorState | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _state() -> _OrchestratorState:
|
|
162
|
+
global _STATE
|
|
163
|
+
if _STATE is None:
|
|
164
|
+
_STATE = _OrchestratorState()
|
|
165
|
+
return _STATE
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ─────────────────────────── Event types ──────────────────────────────
|
|
169
|
+
# Plain dicts (JSON-serialisable for SSE). Same shape as LocalForge's
|
|
170
|
+
# OrchestratorEvent union.
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _emit_event(event: dict[str, Any]) -> None:
|
|
174
|
+
"""Push an event to all matching subscribers (non-blocking)."""
|
|
175
|
+
sid = event.get("session_id")
|
|
176
|
+
state = _state()
|
|
177
|
+
if isinstance(sid, int):
|
|
178
|
+
for q in state.subscribers.get(sid, []):
|
|
179
|
+
try:
|
|
180
|
+
q.put_nowait(event)
|
|
181
|
+
except asyncio.QueueFull:
|
|
182
|
+
pass
|
|
183
|
+
for q in state.global_subscribers:
|
|
184
|
+
try:
|
|
185
|
+
q.put_nowait(event)
|
|
186
|
+
except asyncio.QueueFull:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _persist_log(session_id: int, feature_id: int | None, message: str,
|
|
191
|
+
message_type: str = "info") -> int:
|
|
192
|
+
"""Append a log row, return its id."""
|
|
193
|
+
with session_scope() as sess:
|
|
194
|
+
row = ForgeLog(
|
|
195
|
+
session_id=session_id,
|
|
196
|
+
feature_id=feature_id,
|
|
197
|
+
message=message,
|
|
198
|
+
message_type=message_type,
|
|
199
|
+
)
|
|
200
|
+
sess.add(row)
|
|
201
|
+
sess.flush()
|
|
202
|
+
log_id = row.id
|
|
203
|
+
_emit_event({
|
|
204
|
+
"type": "log",
|
|
205
|
+
"session_id": session_id,
|
|
206
|
+
"feature_id": feature_id,
|
|
207
|
+
"message": message,
|
|
208
|
+
"message_type": message_type,
|
|
209
|
+
"log_id": log_id,
|
|
210
|
+
"created_at": datetime.utcnow().isoformat(),
|
|
211
|
+
})
|
|
212
|
+
return log_id
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _emit_status(session_id: int, feature_id: int | None,
|
|
216
|
+
session_status: str, feature_status: str | None = None,
|
|
217
|
+
feature_title: str | None = None) -> None:
|
|
218
|
+
_emit_event({
|
|
219
|
+
"type": "status",
|
|
220
|
+
"session_id": session_id,
|
|
221
|
+
"feature_id": feature_id,
|
|
222
|
+
"session_status": session_status,
|
|
223
|
+
"feature_status": feature_status,
|
|
224
|
+
"feature_title": feature_title,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ─────────────────── DB queries (next ready, etc.) ─────────────────────
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def find_next_ready_feature(project_id: int) -> dict[str, Any] | None:
|
|
232
|
+
"""Highest-priority backlog feature whose deps are all completed.
|
|
233
|
+
Returns a plain dict (so we don't hold an ORM session open across
|
|
234
|
+
awaits).
|
|
235
|
+
|
|
236
|
+
ADR 0006 Phase C: when multiple features tie on the lowest
|
|
237
|
+
priority, ask the forge_advisor to break the tie. Order modes
|
|
238
|
+
via ``forge.backlog_order`` setting:
|
|
239
|
+
- ``caudate_easy_first`` (default): pick highest success_prob
|
|
240
|
+
- ``caudate_hard_first``: pick lowest success_prob
|
|
241
|
+
- ``priority_only``: legacy behaviour, first by id ascending
|
|
242
|
+
"""
|
|
243
|
+
with session_scope() as sess:
|
|
244
|
+
# Pull all backlog features for this project, ordered by priority.
|
|
245
|
+
rows = (
|
|
246
|
+
sess.query(ForgeFeature)
|
|
247
|
+
.filter(
|
|
248
|
+
ForgeFeature.project_id == project_id,
|
|
249
|
+
ForgeFeature.status == "backlog",
|
|
250
|
+
)
|
|
251
|
+
.order_by(ForgeFeature.priority.asc(), ForgeFeature.id.asc())
|
|
252
|
+
.all()
|
|
253
|
+
)
|
|
254
|
+
# Compute readiness for each (deps all completed).
|
|
255
|
+
ready: list[ForgeFeature] = []
|
|
256
|
+
for r in rows:
|
|
257
|
+
deps = (
|
|
258
|
+
sess.query(ForgeFeatureDep.depends_on_feature_id)
|
|
259
|
+
.filter(ForgeFeatureDep.feature_id == r.id)
|
|
260
|
+
.all()
|
|
261
|
+
)
|
|
262
|
+
dep_ids = [d[0] for d in deps]
|
|
263
|
+
if not dep_ids:
|
|
264
|
+
ready.append(r)
|
|
265
|
+
continue
|
|
266
|
+
completed = (
|
|
267
|
+
sess.query(ForgeFeature)
|
|
268
|
+
.filter(
|
|
269
|
+
ForgeFeature.id.in_(dep_ids),
|
|
270
|
+
ForgeFeature.status == "completed",
|
|
271
|
+
)
|
|
272
|
+
.count()
|
|
273
|
+
)
|
|
274
|
+
if completed == len(dep_ids):
|
|
275
|
+
ready.append(r)
|
|
276
|
+
if not ready:
|
|
277
|
+
return None
|
|
278
|
+
# Group by priority (asc); within the lowest-priority group
|
|
279
|
+
# apply Caudate-driven tiebreak.
|
|
280
|
+
lowest_prio = ready[0].priority
|
|
281
|
+
head = [r for r in ready if r.priority == lowest_prio]
|
|
282
|
+
if len(head) == 1:
|
|
283
|
+
return _row_to_dict(head[0])
|
|
284
|
+
chosen = _caudate_tiebreak(head, project_id)
|
|
285
|
+
return _row_to_dict(chosen)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _caudate_tiebreak(
|
|
289
|
+
candidates: list[ForgeFeature], project_id: int,
|
|
290
|
+
) -> ForgeFeature:
|
|
291
|
+
"""ADR 0006 Phase C tiebreaker. Defaults to easy-first; honours
|
|
292
|
+
the per-project ``forge.backlog_order`` override. Falls back to
|
|
293
|
+
id-asc when the advisor is unavailable."""
|
|
294
|
+
mode = _get_forge_setting("backlog_order", "caudate_easy_first")
|
|
295
|
+
if mode == "priority_only":
|
|
296
|
+
return min(candidates, key=lambda r: r.id)
|
|
297
|
+
try:
|
|
298
|
+
from nn.forge_advisor import predict_feature_difficulty
|
|
299
|
+
except Exception:
|
|
300
|
+
return min(candidates, key=lambda r: r.id)
|
|
301
|
+
# Resolve a model id for the prediction (per-project effective
|
|
302
|
+
# model or the global default). Best-effort.
|
|
303
|
+
try:
|
|
304
|
+
from core.settings import Settings
|
|
305
|
+
s = Settings.load()
|
|
306
|
+
model_id = (
|
|
307
|
+
s.get("system1") or s.get("system2") or s.get("model")
|
|
308
|
+
)
|
|
309
|
+
except Exception:
|
|
310
|
+
model_id = None
|
|
311
|
+
scored = []
|
|
312
|
+
for r in candidates:
|
|
313
|
+
text = f"{r.title}\n\n{r.description or ''}"
|
|
314
|
+
try:
|
|
315
|
+
p = predict_feature_difficulty(text, model_id).success_prob
|
|
316
|
+
except Exception:
|
|
317
|
+
p = 0.5
|
|
318
|
+
scored.append((p, r))
|
|
319
|
+
reverse = (mode != "caudate_hard_first") # easy_first → highest p first
|
|
320
|
+
scored.sort(key=lambda x: x[0], reverse=reverse)
|
|
321
|
+
return scored[0][1]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _row_to_dict(r: ForgeFeature) -> dict[str, Any]:
|
|
325
|
+
return {
|
|
326
|
+
"id": r.id,
|
|
327
|
+
"project_id": r.project_id,
|
|
328
|
+
"title": r.title,
|
|
329
|
+
"description": r.description,
|
|
330
|
+
"acceptance_criteria": r.acceptance_criteria,
|
|
331
|
+
"status": r.status,
|
|
332
|
+
"priority": r.priority,
|
|
333
|
+
"category": r.category,
|
|
334
|
+
"verify_command": r.verify_command,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_kanban_state(project_id: int) -> dict[str, Any]:
|
|
339
|
+
"""Return projects/features/running info for the UI in one shot."""
|
|
340
|
+
with session_scope() as sess:
|
|
341
|
+
proj = sess.get(ForgeProject, project_id)
|
|
342
|
+
if not proj:
|
|
343
|
+
raise OrchestratorError(f"project {project_id} not found", 404)
|
|
344
|
+
feats = (
|
|
345
|
+
sess.query(ForgeFeature)
|
|
346
|
+
.filter(ForgeFeature.project_id == project_id)
|
|
347
|
+
.order_by(ForgeFeature.priority.asc(), ForgeFeature.id.asc())
|
|
348
|
+
.all()
|
|
349
|
+
)
|
|
350
|
+
feat_dicts = []
|
|
351
|
+
for r in feats:
|
|
352
|
+
deps = (
|
|
353
|
+
sess.query(ForgeFeatureDep.depends_on_feature_id)
|
|
354
|
+
.filter(ForgeFeatureDep.feature_id == r.id)
|
|
355
|
+
.all()
|
|
356
|
+
)
|
|
357
|
+
d = _row_to_dict(r)
|
|
358
|
+
d["depends_on"] = [x[0] for x in deps]
|
|
359
|
+
feat_dicts.append(d)
|
|
360
|
+
|
|
361
|
+
running_feature_ids = {
|
|
362
|
+
rs.feature_id for rs in _state().running.values()
|
|
363
|
+
if rs.project_id == project_id
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
"project": {
|
|
367
|
+
"id": proj.id,
|
|
368
|
+
"name": proj.name,
|
|
369
|
+
"description": proj.description,
|
|
370
|
+
"folder_path": proj.folder_path,
|
|
371
|
+
"status": proj.status,
|
|
372
|
+
},
|
|
373
|
+
"features": feat_dicts,
|
|
374
|
+
"running_feature_ids": sorted(running_feature_ids),
|
|
375
|
+
"max_concurrent": _max_concurrent_for_project(project_id),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_running_for_project(project_id: int) -> list[int]:
|
|
380
|
+
return [
|
|
381
|
+
rs.session_id for rs in _state().running.values()
|
|
382
|
+
if rs.project_id == project_id
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ─────────────────────── Reconciliation on startup ─────────────────────
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def reconcile_orphaned_sessions() -> int:
|
|
390
|
+
"""Sweep DB for sessions marked in_progress that have no live task,
|
|
391
|
+
flip them to terminated, reset their features to backlog. Called
|
|
392
|
+
once at process start. Returns count reclaimed."""
|
|
393
|
+
init()
|
|
394
|
+
n = 0
|
|
395
|
+
state = _state()
|
|
396
|
+
with session_scope() as sess:
|
|
397
|
+
rows = (
|
|
398
|
+
sess.query(ForgeSession)
|
|
399
|
+
.filter(ForgeSession.status == "in_progress")
|
|
400
|
+
.all()
|
|
401
|
+
)
|
|
402
|
+
for r in rows:
|
|
403
|
+
if r.id in state.running:
|
|
404
|
+
continue # actually live in this process
|
|
405
|
+
r.status = "terminated"
|
|
406
|
+
r.ended_at = datetime.utcnow()
|
|
407
|
+
if r.feature_id is not None:
|
|
408
|
+
feat = sess.get(ForgeFeature, r.feature_id)
|
|
409
|
+
if feat and feat.status == "in_progress":
|
|
410
|
+
feat.status = "backlog"
|
|
411
|
+
n += 1
|
|
412
|
+
if n:
|
|
413
|
+
logger.info(f"reconciled {n} orphaned forge sessions")
|
|
414
|
+
return n
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ───────────────────────── Start a session ────────────────────────────
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def start_orchestrator(project_id: int) -> dict[str, Any]:
|
|
421
|
+
"""Pick the next ready feature, create a session, kick off the
|
|
422
|
+
runner. Returns {session_id, feature_id, started: True}.
|
|
423
|
+
|
|
424
|
+
Raises OrchestratorError(409) if the project has hit max_concurrent
|
|
425
|
+
or there's nothing ready to work on.
|
|
426
|
+
"""
|
|
427
|
+
init()
|
|
428
|
+
reconcile_orphaned_sessions()
|
|
429
|
+
|
|
430
|
+
state = _state()
|
|
431
|
+
running = [
|
|
432
|
+
rs for rs in state.running.values() if rs.project_id == project_id
|
|
433
|
+
]
|
|
434
|
+
cap = _max_concurrent_for_project(project_id)
|
|
435
|
+
if len(running) >= cap:
|
|
436
|
+
raise OrchestratorError(
|
|
437
|
+
f"max concurrent agents reached ({len(running)}/{cap})", 409,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
feature = find_next_ready_feature(project_id)
|
|
441
|
+
if feature is None:
|
|
442
|
+
raise OrchestratorError("no ready features (backlog empty or blocked)", 409)
|
|
443
|
+
|
|
444
|
+
# Flip feature → in_progress + create session row in one transaction.
|
|
445
|
+
with session_scope() as sess:
|
|
446
|
+
feat = sess.get(ForgeFeature, feature["id"])
|
|
447
|
+
if feat is None or feat.status != "backlog":
|
|
448
|
+
raise OrchestratorError("feature changed underneath us; retry", 409)
|
|
449
|
+
feat.status = "in_progress"
|
|
450
|
+
sess.flush()
|
|
451
|
+
|
|
452
|
+
session_row = ForgeSession(
|
|
453
|
+
project_id=project_id,
|
|
454
|
+
feature_id=feature["id"],
|
|
455
|
+
session_type="coding",
|
|
456
|
+
status="in_progress",
|
|
457
|
+
)
|
|
458
|
+
sess.add(session_row)
|
|
459
|
+
sess.flush()
|
|
460
|
+
session_id = session_row.id
|
|
461
|
+
|
|
462
|
+
proj = sess.get(ForgeProject, project_id)
|
|
463
|
+
project_dir = proj.folder_path if proj else None
|
|
464
|
+
|
|
465
|
+
_persist_log(
|
|
466
|
+
session_id, feature["id"],
|
|
467
|
+
f"Orchestrator starting coding agent for \"{feature['title']}\"",
|
|
468
|
+
"info",
|
|
469
|
+
)
|
|
470
|
+
_emit_status(
|
|
471
|
+
session_id, feature["id"], "in_progress",
|
|
472
|
+
feature_status="in_progress", feature_title=feature["title"],
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
loop = asyncio.get_event_loop()
|
|
476
|
+
task = loop.create_task(
|
|
477
|
+
_run_session_with_watchdog(
|
|
478
|
+
session_id=session_id,
|
|
479
|
+
project_id=project_id,
|
|
480
|
+
feature=feature,
|
|
481
|
+
project_dir=project_dir,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
state.running[session_id] = _RunningSession(
|
|
485
|
+
session_id=session_id,
|
|
486
|
+
project_id=project_id,
|
|
487
|
+
feature_id=feature["id"],
|
|
488
|
+
task=task,
|
|
489
|
+
started_at=loop.time(),
|
|
490
|
+
)
|
|
491
|
+
return {
|
|
492
|
+
"session_id": session_id,
|
|
493
|
+
"feature_id": feature["id"],
|
|
494
|
+
"feature_title": feature["title"],
|
|
495
|
+
"started": True,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ───────────────────────── Per-session runner ─────────────────────────
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def _run_session_with_watchdog(
|
|
503
|
+
*,
|
|
504
|
+
session_id: int,
|
|
505
|
+
project_id: int,
|
|
506
|
+
feature: dict[str, Any],
|
|
507
|
+
project_dir: str | None,
|
|
508
|
+
) -> None:
|
|
509
|
+
"""Wrap _run_feature in an asyncio timeout. On timeout/cancel,
|
|
510
|
+
finalise as `terminated`."""
|
|
511
|
+
timeout_s = _session_timeout_s()
|
|
512
|
+
try:
|
|
513
|
+
# asyncio.timeout() was added in 3.11; use wait_for for 3.10
|
|
514
|
+
# compatibility. Same semantics — TimeoutError on deadline.
|
|
515
|
+
outcome, reason = await asyncio.wait_for(
|
|
516
|
+
_run_feature(
|
|
517
|
+
session_id=session_id,
|
|
518
|
+
feature=feature,
|
|
519
|
+
project_dir=project_dir,
|
|
520
|
+
),
|
|
521
|
+
timeout=timeout_s,
|
|
522
|
+
)
|
|
523
|
+
except asyncio.TimeoutError:
|
|
524
|
+
_persist_log(
|
|
525
|
+
session_id, feature["id"],
|
|
526
|
+
f"Watchdog: session exceeded {timeout_s // 60}min timeout",
|
|
527
|
+
"error",
|
|
528
|
+
)
|
|
529
|
+
outcome, reason = "terminated", "timeout"
|
|
530
|
+
except asyncio.CancelledError:
|
|
531
|
+
_persist_log(
|
|
532
|
+
session_id, feature["id"],
|
|
533
|
+
"Session cancelled by orchestrator", "info",
|
|
534
|
+
)
|
|
535
|
+
outcome, reason = "terminated", "cancelled"
|
|
536
|
+
finalize_session(session_id, outcome, reason)
|
|
537
|
+
raise
|
|
538
|
+
except Exception as e:
|
|
539
|
+
logger.exception(f"forge runner crashed for session {session_id}")
|
|
540
|
+
_persist_log(
|
|
541
|
+
session_id, feature["id"], f"Runner crashed: {e}", "error",
|
|
542
|
+
)
|
|
543
|
+
outcome, reason = "failed", str(e)
|
|
544
|
+
|
|
545
|
+
finalize_session(session_id, outcome, reason)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def _run_feature(
|
|
549
|
+
*,
|
|
550
|
+
session_id: int,
|
|
551
|
+
feature: dict[str, Any],
|
|
552
|
+
project_dir: str | None,
|
|
553
|
+
) -> tuple[str, str | None]:
|
|
554
|
+
"""Run one feature to completion. Returns (outcome, reason)."""
|
|
555
|
+
# Build a CognosAgent for the project. We lazy-import here so the
|
|
556
|
+
# orchestrator module can be imported without yanking the world in.
|
|
557
|
+
from core.agent import CognosAgent
|
|
558
|
+
import os
|
|
559
|
+
|
|
560
|
+
prev_cwd = os.getcwd() if project_dir else None
|
|
561
|
+
if project_dir:
|
|
562
|
+
Path(project_dir).mkdir(parents=True, exist_ok=True)
|
|
563
|
+
os.chdir(project_dir)
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
# ADR 0006 Phase B — Caudate-biased model selection per
|
|
567
|
+
# feature. If the advisor predicts this feature has a low
|
|
568
|
+
# success probability AND a system2 is configured, override
|
|
569
|
+
# the agent's primary model to system2 from turn one. Default
|
|
570
|
+
# behaviour (no override) keeps ADR 0005's local-first chain.
|
|
571
|
+
agent_model_override: str | None = None
|
|
572
|
+
try:
|
|
573
|
+
from nn.forge_advisor import predict_feature_difficulty
|
|
574
|
+
from core.settings import Settings
|
|
575
|
+
s = Settings.load()
|
|
576
|
+
sys1 = s.get("system1")
|
|
577
|
+
sys2 = s.get("system2")
|
|
578
|
+
promote_threshold = float(
|
|
579
|
+
_get_forge_setting("caudate_promote_threshold", "0.4")
|
|
580
|
+
)
|
|
581
|
+
text = f"{feature.get('title','')}\n\n{feature.get('description','')}"
|
|
582
|
+
pred = predict_feature_difficulty(text, sys1)
|
|
583
|
+
if pred.success_prob < promote_threshold and sys2:
|
|
584
|
+
agent_model_override = sys2
|
|
585
|
+
logger.info(
|
|
586
|
+
f"forge: promoting feature #{feature['id']} to "
|
|
587
|
+
f"{sys2!r} (predicted success {pred.success_prob:.2f} "
|
|
588
|
+
f"below threshold {promote_threshold:.2f})"
|
|
589
|
+
)
|
|
590
|
+
except Exception as e:
|
|
591
|
+
logger.debug(f"caudate model bias skipped: {e}")
|
|
592
|
+
|
|
593
|
+
# Forge feature runs are autonomous — no human is waiting to
|
|
594
|
+
# answer "may I write this file?" prompts. Use BYPASS mode so
|
|
595
|
+
# the agent can actually do its job. The project's folder is a
|
|
596
|
+
# sandbox dir we already chdir'd into, which keeps the blast
|
|
597
|
+
# radius local.
|
|
598
|
+
agent_kwargs: dict[str, Any] = {"permission_mode": "bypass"}
|
|
599
|
+
if agent_model_override:
|
|
600
|
+
agent_kwargs["model"] = agent_model_override
|
|
601
|
+
agent = CognosAgent(**agent_kwargs)
|
|
602
|
+
|
|
603
|
+
# Pipe every tool call into the Forge live-log panel so the
|
|
604
|
+
# user can see what the agent is actually doing without
|
|
605
|
+
# tailing journalctl. PRE_TOOL_USE fires before the call,
|
|
606
|
+
# POST_TOOL_USE after success, POST_TOOL_USE_FAILURE on error.
|
|
607
|
+
# Each handler persists a forge_logs row which SSE-broadcasts
|
|
608
|
+
# to the UI in real time.
|
|
609
|
+
from core.hooks import HookEvent as _HE
|
|
610
|
+
|
|
611
|
+
def _short(value: Any, n: int = 220) -> str:
|
|
612
|
+
s = str(value).replace("\n", " ")
|
|
613
|
+
return s if len(s) <= n else s[:n] + "…"
|
|
614
|
+
|
|
615
|
+
async def _on_pre(_event: str, payload: dict[str, Any]):
|
|
616
|
+
tool = payload.get("tool_name", "?")
|
|
617
|
+
args = _short(payload.get("args", {}))
|
|
618
|
+
_persist_log(
|
|
619
|
+
session_id, feature["id"],
|
|
620
|
+
f"→ {tool}({args})", "action",
|
|
621
|
+
)
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
async def _on_post(_event: str, payload: dict[str, Any]):
|
|
625
|
+
tool = payload.get("tool_name", "?")
|
|
626
|
+
result = _short(payload.get("result", ""), n=320)
|
|
627
|
+
_persist_log(
|
|
628
|
+
session_id, feature["id"],
|
|
629
|
+
f"✓ {tool} → {result}", "info",
|
|
630
|
+
)
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
async def _on_post_fail(_event: str, payload: dict[str, Any]):
|
|
634
|
+
tool = payload.get("tool_name", "?")
|
|
635
|
+
err = _short(payload.get("error", "?"), n=320)
|
|
636
|
+
_persist_log(
|
|
637
|
+
session_id, feature["id"],
|
|
638
|
+
f"✗ {tool} failed: {err}", "error",
|
|
639
|
+
)
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
# AgenticLoop's HookManager (always present, distinct from
|
|
643
|
+
# CognosAgent.hooks which is None when no external manager
|
|
644
|
+
# was passed in).
|
|
645
|
+
hooks_mgr = getattr(agent, "agentic", None) and agent.agentic.hooks
|
|
646
|
+
if hooks_mgr is not None:
|
|
647
|
+
hooks_mgr.register(_HE.PRE_TOOL_USE, _on_pre)
|
|
648
|
+
hooks_mgr.register(_HE.POST_TOOL_USE, _on_post)
|
|
649
|
+
hooks_mgr.register(_HE.POST_TOOL_USE_FAILURE, _on_post_fail)
|
|
650
|
+
|
|
651
|
+
prompt = _build_feature_prompt(feature)
|
|
652
|
+
_persist_log(session_id, feature["id"],
|
|
653
|
+
f"Agent starting on: {feature['title']}", "action")
|
|
654
|
+
|
|
655
|
+
# Use the agent's chat path — agentic loop, full tool suite,
|
|
656
|
+
# sessions, hooks, Caudate observation. Returns the final text.
|
|
657
|
+
# Wrap in subscription_auth_scope so Anthropic-routed System-2
|
|
658
|
+
# calls find the Claude Code OAuth token instead of crashing
|
|
659
|
+
# on missing ANTHROPIC_API_KEY.
|
|
660
|
+
# Also bind the Forge project so the forge_* feature-CRUD tools
|
|
661
|
+
# know which backlog they operate on.
|
|
662
|
+
from core.anthropic_auth import subscription_auth_scope
|
|
663
|
+
from execution.tools.forge_feature_tools import forge_project_scope
|
|
664
|
+
try:
|
|
665
|
+
with subscription_auth_scope(), \
|
|
666
|
+
forge_project_scope(feature["project_id"]):
|
|
667
|
+
response = await agent.chat(prompt)
|
|
668
|
+
except Exception as e:
|
|
669
|
+
return "failed", f"agent.chat raised: {e}"
|
|
670
|
+
|
|
671
|
+
# The chat response shape varies by build; just stringify.
|
|
672
|
+
result_text = str(getattr(response, "content", response) or "")
|
|
673
|
+
_persist_log(
|
|
674
|
+
session_id, feature["id"],
|
|
675
|
+
f"Agent finished: {result_text[:300]}", "info",
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# ADR 0001: agent attestation is one half of the done contract.
|
|
679
|
+
# Scan for give-up phrases — if any appear, the feature is NOT
|
|
680
|
+
# done regardless of what the verify_command says. The verify
|
|
681
|
+
# half is checked below this block.
|
|
682
|
+
give_up = _detect_give_up(result_text)
|
|
683
|
+
if give_up:
|
|
684
|
+
_persist_log(
|
|
685
|
+
session_id, feature["id"],
|
|
686
|
+
f"Agent attestation failed — found give-up phrase: "
|
|
687
|
+
f"{give_up!r}", "error",
|
|
688
|
+
)
|
|
689
|
+
return "failed", f"agent gave up: {give_up}"
|
|
690
|
+
|
|
691
|
+
# Optional verify_command — must exit 0 to count as success.
|
|
692
|
+
verify = feature.get("verify_command")
|
|
693
|
+
if verify:
|
|
694
|
+
ok, output = await _run_verify(verify, project_dir)
|
|
695
|
+
_persist_log(
|
|
696
|
+
session_id, feature["id"],
|
|
697
|
+
f"verify: {verify}\n{output[:1500]}",
|
|
698
|
+
"test_result" if ok else "error",
|
|
699
|
+
)
|
|
700
|
+
if not ok:
|
|
701
|
+
return "failed", "verify_command failed"
|
|
702
|
+
return "success", None
|
|
703
|
+
finally:
|
|
704
|
+
if prev_cwd:
|
|
705
|
+
try:
|
|
706
|
+
os.chdir(prev_cwd)
|
|
707
|
+
except Exception:
|
|
708
|
+
pass
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _build_feature_prompt(feature: dict[str, Any]) -> str:
|
|
712
|
+
"""Render the feature into a coder prompt the agent can act on."""
|
|
713
|
+
parts = [
|
|
714
|
+
f"# Feature: {feature['title']}",
|
|
715
|
+
"",
|
|
716
|
+
feature.get("description") or "",
|
|
717
|
+
"",
|
|
718
|
+
"## Acceptance criteria",
|
|
719
|
+
feature.get("acceptance_criteria") or "(none specified)",
|
|
720
|
+
"",
|
|
721
|
+
"## Rules",
|
|
722
|
+
"- Implement this feature in the current working directory.",
|
|
723
|
+
"- Use the tools available (Read, Write, Edit, Bash, Grep, "
|
|
724
|
+
"Glob, etc.) — DO NOT use code_interpreter or any browser-side "
|
|
725
|
+
"Python shell, those will fail.",
|
|
726
|
+
"- DO NOT write README.md, CHECKLIST.md, USAGE_EXAMPLES.md, "
|
|
727
|
+
"TECHNICAL_SPEC.md, DEPLOYMENT.md, or any other freeform "
|
|
728
|
+
"documentation files unless an acceptance criterion explicitly "
|
|
729
|
+
"requires them. Focus on the code and tests called for above.",
|
|
730
|
+
"- Run the verify_command yourself first if you can, before "
|
|
731
|
+
"declaring done — it'll save a retry cycle.",
|
|
732
|
+
"- When done, respond with a short summary of what changed "
|
|
733
|
+
"(one paragraph maximum).",
|
|
734
|
+
]
|
|
735
|
+
return "\n".join(parts)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# ADR 0001 — give-up phrase detection for the attestation half of the
|
|
739
|
+
# done contract. Conservative: only matches phrases that unambiguously
|
|
740
|
+
# signal the agent stopped short. Lower-cases the response so e.g.
|
|
741
|
+
# "Max iterations reached" matches "max iterations reached".
|
|
742
|
+
_GIVE_UP_PHRASES = (
|
|
743
|
+
"i couldn't",
|
|
744
|
+
"i could not",
|
|
745
|
+
"i can't",
|
|
746
|
+
"i cannot",
|
|
747
|
+
"i gave up",
|
|
748
|
+
"i give up",
|
|
749
|
+
"once permissions are restored",
|
|
750
|
+
"blocked by",
|
|
751
|
+
"permission denied",
|
|
752
|
+
"tools are being denied",
|
|
753
|
+
"max iterations reached",
|
|
754
|
+
"max_iterations reached",
|
|
755
|
+
"i was unable to",
|
|
756
|
+
"unable to complete",
|
|
757
|
+
"stopping here",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _detect_give_up(text: str) -> str | None:
|
|
762
|
+
"""Return the matching phrase if the agent appears to have given
|
|
763
|
+
up, else None. Used by _run_feature to enforce ADR 0001's
|
|
764
|
+
attestation half of the done contract."""
|
|
765
|
+
if not text:
|
|
766
|
+
# An empty response from a coding agent is itself a give-up.
|
|
767
|
+
return "(empty agent response)"
|
|
768
|
+
lowered = text.lower()
|
|
769
|
+
for phrase in _GIVE_UP_PHRASES:
|
|
770
|
+
if phrase in lowered:
|
|
771
|
+
return phrase
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def _run_verify(cmd: str, project_dir: str | None) -> tuple[bool, str]:
|
|
776
|
+
"""Run the verify_command. Returns (success, combined-output).
|
|
777
|
+
|
|
778
|
+
Compatibility shim: many bootstrapper outputs use ``python`` even
|
|
779
|
+
when the host only has ``python3`` on PATH (most modern Linux
|
|
780
|
+
distros). If ``python`` isn't installed but ``python3`` is, we
|
|
781
|
+
rewrite the command so the verify still works without re-running
|
|
782
|
+
the bootstrapper.
|
|
783
|
+
"""
|
|
784
|
+
import os
|
|
785
|
+
import shutil
|
|
786
|
+
|
|
787
|
+
fixed = cmd
|
|
788
|
+
if shutil.which("python") is None and shutil.which("python3") is not None:
|
|
789
|
+
# Replace `python ` at start or after whitespace; leave
|
|
790
|
+
# `python3`, `python.x`, and quoted "python" strings alone.
|
|
791
|
+
import re as _re
|
|
792
|
+
fixed = _re.sub(r"(?<![\w.])python(?=\s|$)", "python3", cmd)
|
|
793
|
+
|
|
794
|
+
proc = await asyncio.create_subprocess_shell(
|
|
795
|
+
fixed,
|
|
796
|
+
stdout=asyncio.subprocess.PIPE,
|
|
797
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
798
|
+
cwd=project_dir or os.getcwd(),
|
|
799
|
+
)
|
|
800
|
+
try:
|
|
801
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=300)
|
|
802
|
+
except asyncio.TimeoutError:
|
|
803
|
+
proc.kill()
|
|
804
|
+
return False, "[verify] timed out after 300s"
|
|
805
|
+
text = stdout.decode("utf-8", errors="replace")
|
|
806
|
+
rc = proc.returncode
|
|
807
|
+
# pytest exit codes:
|
|
808
|
+
# 0 — all tests passed
|
|
809
|
+
# 5 — no tests collected (treat as success: a foundation feature
|
|
810
|
+
# may exist without any tests yet; failing the whole feature
|
|
811
|
+
# on a missing test file is too strict)
|
|
812
|
+
# See https://docs.pytest.org/en/stable/reference/exit-codes.html
|
|
813
|
+
is_pytest = "pytest" in fixed.split()[0:2] or " pytest " in (" " + fixed + " ")
|
|
814
|
+
success = rc == 0 or (is_pytest and rc == 5)
|
|
815
|
+
note = ""
|
|
816
|
+
if fixed != cmd:
|
|
817
|
+
note += f"[verify cmd: {fixed!r}]\n"
|
|
818
|
+
if is_pytest and rc == 5:
|
|
819
|
+
note += "[pytest: no tests collected — treating as PASS for now]\n"
|
|
820
|
+
return success, note + text
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ───────────────────────── Finalise + auto-continue ────────────────────
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def finalize_session(session_id: int, outcome: str,
|
|
827
|
+
reason: str | None = None) -> None:
|
|
828
|
+
"""Idempotent finalise. Called from the runner OR from stop_session.
|
|
829
|
+
|
|
830
|
+
outcome ∈ {success, failed, terminated}.
|
|
831
|
+
"""
|
|
832
|
+
state = _state()
|
|
833
|
+
rs = state.running.pop(session_id, None)
|
|
834
|
+
|
|
835
|
+
# Hold all the values we need *out* of the session before it closes
|
|
836
|
+
# — otherwise we get DetachedInstanceError when reading later.
|
|
837
|
+
new_session_status: str | None = None
|
|
838
|
+
feature_title: str | None = None
|
|
839
|
+
feature_status: str | None = None
|
|
840
|
+
feature_id: int | None = None
|
|
841
|
+
project_id: int | None = None
|
|
842
|
+
# ADR 0003: when the failure cap fires and revision_count==0, the
|
|
843
|
+
# 3-fail branch sets this to the feature id; we trigger the
|
|
844
|
+
# revision attempt after the DB transaction closes so it isn't
|
|
845
|
+
# mixed with the finalize write.
|
|
846
|
+
revision_due_for_feature_id: int | None = None
|
|
847
|
+
|
|
848
|
+
with session_scope() as sess:
|
|
849
|
+
srow = sess.get(ForgeSession, session_id)
|
|
850
|
+
if srow is None or srow.status != "in_progress":
|
|
851
|
+
return # already finalised
|
|
852
|
+
new_session_status = ({"success": "completed",
|
|
853
|
+
"failed": "failed",
|
|
854
|
+
"terminated": "terminated"}[outcome])
|
|
855
|
+
srow.status = new_session_status
|
|
856
|
+
srow.ended_at = datetime.utcnow()
|
|
857
|
+
project_id = srow.project_id
|
|
858
|
+
|
|
859
|
+
if srow.feature_id is not None:
|
|
860
|
+
feat = sess.get(ForgeFeature, srow.feature_id)
|
|
861
|
+
if feat is not None:
|
|
862
|
+
if outcome == "success":
|
|
863
|
+
feat.status = "completed"
|
|
864
|
+
elif outcome == "failed":
|
|
865
|
+
# Cap consecutive failures so a broken feature can't
|
|
866
|
+
# trap auto-continue in a tight retry loop. We count
|
|
867
|
+
# how many sessions for THIS feature have ended with
|
|
868
|
+
# status='failed' since the last 'completed' (or
|
|
869
|
+
# since the feature was created). After N in a row,
|
|
870
|
+
# park the feature in 'failed' instead of bouncing
|
|
871
|
+
# back to the backlog.
|
|
872
|
+
fail_streak = (
|
|
873
|
+
sess.query(ForgeSession)
|
|
874
|
+
.filter_by(feature_id=feat.id, status="failed")
|
|
875
|
+
.count()
|
|
876
|
+
)
|
|
877
|
+
# ADR 0006 Phase D — early-revision when retry is
|
|
878
|
+
# likely doomed. After the FIRST failure, ask
|
|
879
|
+
# forge_advisor whether the next retry is likely
|
|
880
|
+
# to fail; if so, skip straight to revision now.
|
|
881
|
+
early_revise = False
|
|
882
|
+
if (fail_streak >= 1
|
|
883
|
+
and fail_streak < _MAX_FEATURE_FAILURES
|
|
884
|
+
and (feat.revision_count or 0) < 1):
|
|
885
|
+
try:
|
|
886
|
+
from nn.forge_advisor import (
|
|
887
|
+
predict_failure_repeats,
|
|
888
|
+
)
|
|
889
|
+
text = (
|
|
890
|
+
f"{feat.title}\n\n{feat.description or ''}"
|
|
891
|
+
)
|
|
892
|
+
p_fail = predict_failure_repeats(
|
|
893
|
+
text, fail_streak,
|
|
894
|
+
)
|
|
895
|
+
threshold = float(
|
|
896
|
+
_get_forge_setting("caudate_skip_threshold", "0.7")
|
|
897
|
+
)
|
|
898
|
+
if p_fail > threshold:
|
|
899
|
+
early_revise = True
|
|
900
|
+
except Exception:
|
|
901
|
+
pass
|
|
902
|
+
|
|
903
|
+
if fail_streak >= _MAX_FEATURE_FAILURES or early_revise:
|
|
904
|
+
# ADR 0003: try a single bootstrapper revision
|
|
905
|
+
# before parking. revision_count tracks whether
|
|
906
|
+
# the revision shot has been spent.
|
|
907
|
+
if (feat.revision_count or 0) < 1:
|
|
908
|
+
# Reset for the revision attempt; orchestrator
|
|
909
|
+
# will replace this feature out-of-band.
|
|
910
|
+
feat.status = "backlog"
|
|
911
|
+
feat.priority = max(0, (feat.priority or 0) + 1)
|
|
912
|
+
revision_due_for_feature_id = feat.id
|
|
913
|
+
else:
|
|
914
|
+
feat.status = "failed"
|
|
915
|
+
feat.priority = max(0, (feat.priority or 0) + 1)
|
|
916
|
+
else:
|
|
917
|
+
feat.status = "backlog"
|
|
918
|
+
feat.priority = max(0, (feat.priority or 0) + 1)
|
|
919
|
+
else: # terminated
|
|
920
|
+
feat.status = "backlog"
|
|
921
|
+
feature_title = feat.title
|
|
922
|
+
feature_status = feat.status
|
|
923
|
+
feature_id = feat.id
|
|
924
|
+
|
|
925
|
+
if outcome == "success":
|
|
926
|
+
_persist_log(session_id, feature_id,
|
|
927
|
+
f"Feature marked completed", "info")
|
|
928
|
+
# Promote project status when every feature finishes — mirrors
|
|
929
|
+
# LocalForge's markProjectCompletedIfAllDone, fires the
|
|
930
|
+
# celebration overlay in the UI.
|
|
931
|
+
if project_id is not None:
|
|
932
|
+
from planning.forge_models import (
|
|
933
|
+
mark_project_completed_if_all_done,
|
|
934
|
+
)
|
|
935
|
+
promoted = mark_project_completed_if_all_done(project_id)
|
|
936
|
+
if promoted:
|
|
937
|
+
_emit_event({
|
|
938
|
+
"type": "status",
|
|
939
|
+
"session_id": session_id,
|
|
940
|
+
"feature_id": None,
|
|
941
|
+
"session_status": "completed",
|
|
942
|
+
"feature_status": None,
|
|
943
|
+
"feature_title": None,
|
|
944
|
+
"project_id": project_id,
|
|
945
|
+
})
|
|
946
|
+
elif outcome == "failed":
|
|
947
|
+
_persist_log(session_id, feature_id,
|
|
948
|
+
f"Feature returned to backlog (priority demoted). "
|
|
949
|
+
f"reason={reason or 'unknown'}",
|
|
950
|
+
"error")
|
|
951
|
+
else:
|
|
952
|
+
_persist_log(session_id, feature_id,
|
|
953
|
+
f"Session stopped — feature returned to backlog. "
|
|
954
|
+
f"reason={reason or 'manual'}",
|
|
955
|
+
"info")
|
|
956
|
+
|
|
957
|
+
_emit_status(
|
|
958
|
+
session_id, feature_id, new_session_status or "terminated",
|
|
959
|
+
feature_status=feature_status, feature_title=feature_title,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
if rs is not None and not rs.task.done():
|
|
963
|
+
rs.task.cancel()
|
|
964
|
+
|
|
965
|
+
# Caudate hook — observe the feature outcome.
|
|
966
|
+
try:
|
|
967
|
+
from nn.observer import observe_feature_outcome
|
|
968
|
+
from core.settings import Settings
|
|
969
|
+
# CognosAgent uses DualLLMProvider when both system1 and system2
|
|
970
|
+
# are configured — each turn is routed by Caudate between the
|
|
971
|
+
# fast (local) and slow (cloud) brain. Previously we recorded
|
|
972
|
+
# only system2 here, which made the outcome log claim every
|
|
973
|
+
# feature was run by claude-haiku even when most turns hit the
|
|
974
|
+
# local Ollama model. Record the actual configuration so the
|
|
975
|
+
# log reflects what was available; per-turn routing detail
|
|
976
|
+
# lives in the agent's own session messages.
|
|
977
|
+
try:
|
|
978
|
+
settings = Settings.load()
|
|
979
|
+
s1 = settings.get("system1")
|
|
980
|
+
s2 = settings.get("system2")
|
|
981
|
+
single = settings.get("model")
|
|
982
|
+
if s1 and s2:
|
|
983
|
+
model_used = f"cognos-dual:{s1}+{s2}"
|
|
984
|
+
else:
|
|
985
|
+
model_used = s1 or s2 or single or "unknown"
|
|
986
|
+
except Exception:
|
|
987
|
+
model_used = "unknown"
|
|
988
|
+
|
|
989
|
+
# Pull session timing + log count for richer signals.
|
|
990
|
+
feature_text = f"{feature_title or ''}".strip()
|
|
991
|
+
n_logs = 0
|
|
992
|
+
duration_s = 0.0
|
|
993
|
+
try:
|
|
994
|
+
with session_scope() as sess:
|
|
995
|
+
if feature_id:
|
|
996
|
+
feat = sess.get(ForgeFeature, feature_id)
|
|
997
|
+
if feat:
|
|
998
|
+
feature_text = (
|
|
999
|
+
f"{feat.title}\n\n{feat.description or ''}".strip()
|
|
1000
|
+
)
|
|
1001
|
+
if session_id:
|
|
1002
|
+
srow = sess.get(ForgeSession, session_id)
|
|
1003
|
+
if srow and srow.started_at and srow.ended_at:
|
|
1004
|
+
duration_s = (
|
|
1005
|
+
srow.ended_at - srow.started_at
|
|
1006
|
+
).total_seconds()
|
|
1007
|
+
if session_id:
|
|
1008
|
+
from sqlalchemy import func
|
|
1009
|
+
n_logs = (
|
|
1010
|
+
sess.query(func.count(ForgeLog.id))
|
|
1011
|
+
.filter_by(session_id=session_id)
|
|
1012
|
+
.scalar()
|
|
1013
|
+
) or 0
|
|
1014
|
+
except Exception:
|
|
1015
|
+
pass
|
|
1016
|
+
|
|
1017
|
+
observe_feature_outcome(
|
|
1018
|
+
feature_text=feature_text or f"feature#{feature_id}",
|
|
1019
|
+
model_used=str(model_used),
|
|
1020
|
+
n_turns=int(n_logs),
|
|
1021
|
+
n_tool_calls=int(n_logs),
|
|
1022
|
+
success=(outcome == "success"),
|
|
1023
|
+
duration_s=float(duration_s),
|
|
1024
|
+
project_id=project_id,
|
|
1025
|
+
feature_id=feature_id,
|
|
1026
|
+
session_id=session_id,
|
|
1027
|
+
extras={"outcome": outcome, "reason": reason},
|
|
1028
|
+
)
|
|
1029
|
+
except (ImportError, AttributeError):
|
|
1030
|
+
# Observer hook not yet implemented; that's fine.
|
|
1031
|
+
pass
|
|
1032
|
+
except Exception as e:
|
|
1033
|
+
logger.debug(f"Caudate hook failed: {e}")
|
|
1034
|
+
|
|
1035
|
+
# ADR 0003: revision attempt before parking.
|
|
1036
|
+
if revision_due_for_feature_id is not None and project_id is not None:
|
|
1037
|
+
try:
|
|
1038
|
+
loop = asyncio.get_running_loop()
|
|
1039
|
+
loop.create_task(
|
|
1040
|
+
_try_revise_feature(project_id, revision_due_for_feature_id)
|
|
1041
|
+
)
|
|
1042
|
+
except RuntimeError:
|
|
1043
|
+
logger.debug("no running loop; revision deferred")
|
|
1044
|
+
|
|
1045
|
+
# Auto-continue: fill empty agent slots after every finalise.
|
|
1046
|
+
# We need to schedule on the running event loop (we're called from
|
|
1047
|
+
# inside a coroutine, but `finalize_session` itself is sync).
|
|
1048
|
+
if outcome in ("success", "failed") and project_id is not None:
|
|
1049
|
+
try:
|
|
1050
|
+
loop = asyncio.get_running_loop()
|
|
1051
|
+
loop.create_task(_maybe_auto_continue(project_id))
|
|
1052
|
+
except RuntimeError:
|
|
1053
|
+
# No running loop — happens in CLI sync paths or tests
|
|
1054
|
+
# that called finalize_session directly. Ignore; the next
|
|
1055
|
+
# explicit `cognos forge start` will pick up.
|
|
1056
|
+
logger.debug("no running loop; skipping auto-continue")
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
async def _try_revise_feature(project_id: int, feature_id: int) -> None:
|
|
1060
|
+
"""ADR 0003: kick off a single bootstrapper revision pass for a
|
|
1061
|
+
feature that hit the 3-failure cap.
|
|
1062
|
+
|
|
1063
|
+
Pulls the feature row + the failure logs from the last few
|
|
1064
|
+
sessions, calls ``Planner.revise_feature``, and either replaces
|
|
1065
|
+
the original feature in-place (single revision) or splits it
|
|
1066
|
+
into multiple new features (and parks the original as 'failed'
|
|
1067
|
+
with revision_count=1).
|
|
1068
|
+
"""
|
|
1069
|
+
try:
|
|
1070
|
+
# 1. Snapshot the failed feature + collect failure logs
|
|
1071
|
+
with session_scope() as sess:
|
|
1072
|
+
feat = sess.get(ForgeFeature, feature_id)
|
|
1073
|
+
if feat is None:
|
|
1074
|
+
return
|
|
1075
|
+
failed_dict = {
|
|
1076
|
+
"title": feat.title,
|
|
1077
|
+
"description": feat.description,
|
|
1078
|
+
"acceptance_criteria": feat.acceptance_criteria,
|
|
1079
|
+
"verify_command": feat.verify_command,
|
|
1080
|
+
"category": feat.category,
|
|
1081
|
+
"priority": feat.priority,
|
|
1082
|
+
}
|
|
1083
|
+
# Pull logs from this feature's last 3 failed sessions
|
|
1084
|
+
fail_sids = [
|
|
1085
|
+
r.id for r in (
|
|
1086
|
+
sess.query(ForgeSession)
|
|
1087
|
+
.filter_by(feature_id=feature_id, status="failed")
|
|
1088
|
+
.order_by(ForgeSession.id.desc())
|
|
1089
|
+
.limit(3)
|
|
1090
|
+
.all()
|
|
1091
|
+
)
|
|
1092
|
+
]
|
|
1093
|
+
log_rows = (
|
|
1094
|
+
sess.query(ForgeLog)
|
|
1095
|
+
.filter(ForgeLog.session_id.in_(fail_sids))
|
|
1096
|
+
.order_by(ForgeLog.id.asc())
|
|
1097
|
+
.all()
|
|
1098
|
+
if fail_sids else []
|
|
1099
|
+
)
|
|
1100
|
+
failure_logs = "\n".join(
|
|
1101
|
+
f"[s{r.session_id}][{r.message_type}] {r.message}"
|
|
1102
|
+
for r in log_rows
|
|
1103
|
+
)
|
|
1104
|
+
project_name = ""
|
|
1105
|
+
project_desc = ""
|
|
1106
|
+
proj = sess.get(ForgeProject, project_id)
|
|
1107
|
+
if proj is not None:
|
|
1108
|
+
project_name = proj.name
|
|
1109
|
+
project_desc = proj.description or ""
|
|
1110
|
+
|
|
1111
|
+
_persist_log(
|
|
1112
|
+
fail_sids[0] if fail_sids else 0, feature_id,
|
|
1113
|
+
f"ADR 0003 revision: re-decomposing feature #{feature_id}",
|
|
1114
|
+
"action",
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
# 2. Ask the planner for a revised backlog
|
|
1118
|
+
from planning.planner import Planner
|
|
1119
|
+
from llm.provider import LLMProvider
|
|
1120
|
+
from core.settings import Settings
|
|
1121
|
+
from core.anthropic_auth import subscription_auth_scope
|
|
1122
|
+
from config import SYSTEM_1_MODEL, SYSTEM_2_MODEL, LLM_MODEL
|
|
1123
|
+
|
|
1124
|
+
settings = Settings.load()
|
|
1125
|
+
chosen = (
|
|
1126
|
+
settings.get("system1") or SYSTEM_1_MODEL
|
|
1127
|
+
or settings.get("system2") or SYSTEM_2_MODEL
|
|
1128
|
+
or LLM_MODEL
|
|
1129
|
+
)
|
|
1130
|
+
planner = Planner(LLMProvider(model=chosen))
|
|
1131
|
+
try:
|
|
1132
|
+
with subscription_auth_scope():
|
|
1133
|
+
replacements = await planner.revise_feature(
|
|
1134
|
+
failed_feature=failed_dict,
|
|
1135
|
+
failure_logs=failure_logs or "(no logs available)",
|
|
1136
|
+
project_goal=(
|
|
1137
|
+
f"{project_name}\n{project_desc}".strip()
|
|
1138
|
+
or None
|
|
1139
|
+
),
|
|
1140
|
+
)
|
|
1141
|
+
except Exception as e:
|
|
1142
|
+
logger.warning(f"revision LLM call failed: {e}")
|
|
1143
|
+
# Fall back to permanent park
|
|
1144
|
+
with session_scope() as sess:
|
|
1145
|
+
feat = sess.get(ForgeFeature, feature_id)
|
|
1146
|
+
if feat is not None:
|
|
1147
|
+
feat.status = "failed"
|
|
1148
|
+
feat.revision_count = 1
|
|
1149
|
+
return
|
|
1150
|
+
|
|
1151
|
+
if not replacements:
|
|
1152
|
+
with session_scope() as sess:
|
|
1153
|
+
feat = sess.get(ForgeFeature, feature_id)
|
|
1154
|
+
if feat is not None:
|
|
1155
|
+
feat.status = "failed"
|
|
1156
|
+
feat.revision_count = 1
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
# 3. Apply the revision to the DB.
|
|
1160
|
+
# Strategy:
|
|
1161
|
+
# - revision_count incremented to 1 on the original feature.
|
|
1162
|
+
# - If exactly one replacement: rewrite the row in-place
|
|
1163
|
+
# (preserves dependents).
|
|
1164
|
+
# - If multiple: rewrite the original to be the FIRST sub-feature
|
|
1165
|
+
# and create the rest as new features. Each subsequent
|
|
1166
|
+
# sub-feature depends on the previous. Original's existing
|
|
1167
|
+
# dependents (features that pointed at it) continue to point
|
|
1168
|
+
# at the original — which is now the first link in the chain.
|
|
1169
|
+
with session_scope() as sess:
|
|
1170
|
+
feat = sess.get(ForgeFeature, feature_id)
|
|
1171
|
+
if feat is None:
|
|
1172
|
+
return
|
|
1173
|
+
first = replacements[0]
|
|
1174
|
+
feat.title = first.title
|
|
1175
|
+
feat.description = first.description
|
|
1176
|
+
feat.acceptance_criteria = first.acceptance_criteria
|
|
1177
|
+
feat.category = first.category if first.category in (
|
|
1178
|
+
"functional", "style"
|
|
1179
|
+
) else "functional"
|
|
1180
|
+
if first.verify_command:
|
|
1181
|
+
feat.verify_command = first.verify_command
|
|
1182
|
+
feat.status = "backlog"
|
|
1183
|
+
feat.priority = 0
|
|
1184
|
+
feat.revision_count = 1
|
|
1185
|
+
|
|
1186
|
+
new_ids: list[int] = [feature_id]
|
|
1187
|
+
prev_id = feature_id
|
|
1188
|
+
for rep in replacements[1:]:
|
|
1189
|
+
cat = rep.category if rep.category in (
|
|
1190
|
+
"functional", "style"
|
|
1191
|
+
) else "functional"
|
|
1192
|
+
new_row = ForgeFeature(
|
|
1193
|
+
project_id=project_id,
|
|
1194
|
+
title=rep.title,
|
|
1195
|
+
description=rep.description,
|
|
1196
|
+
acceptance_criteria=rep.acceptance_criteria,
|
|
1197
|
+
status="backlog",
|
|
1198
|
+
priority=(rep.priority or 0) + 100, # after originals
|
|
1199
|
+
category=cat,
|
|
1200
|
+
verify_command=rep.verify_command,
|
|
1201
|
+
revision_count=0,
|
|
1202
|
+
)
|
|
1203
|
+
sess.add(new_row)
|
|
1204
|
+
sess.flush()
|
|
1205
|
+
# Wire the new sub-feature to depend on the previous.
|
|
1206
|
+
sess.add(ForgeFeatureDep(
|
|
1207
|
+
feature_id=new_row.id,
|
|
1208
|
+
depends_on_feature_id=prev_id,
|
|
1209
|
+
))
|
|
1210
|
+
new_ids.append(new_row.id)
|
|
1211
|
+
prev_id = new_row.id
|
|
1212
|
+
|
|
1213
|
+
# 4. Broadcast a status event so the kanban refreshes.
|
|
1214
|
+
_emit_event({
|
|
1215
|
+
"type": "status",
|
|
1216
|
+
"session_id": fail_sids[0] if fail_sids else 0,
|
|
1217
|
+
"feature_id": feature_id,
|
|
1218
|
+
"session_status": "revised",
|
|
1219
|
+
"feature_status": "backlog",
|
|
1220
|
+
"feature_title": failed_dict["title"],
|
|
1221
|
+
"project_id": project_id,
|
|
1222
|
+
"revision": {"new_feature_ids": new_ids},
|
|
1223
|
+
})
|
|
1224
|
+
logger.info(
|
|
1225
|
+
f"revised feature #{feature_id} into {len(new_ids)} "
|
|
1226
|
+
f"feature(s): {new_ids}"
|
|
1227
|
+
)
|
|
1228
|
+
except Exception:
|
|
1229
|
+
logger.exception("revision crashed")
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
async def _maybe_auto_continue(project_id: int) -> None:
|
|
1233
|
+
"""If slots are open and there's a ready feature, start it."""
|
|
1234
|
+
try:
|
|
1235
|
+
cap = _max_concurrent_for_project(project_id)
|
|
1236
|
+
running = sum(
|
|
1237
|
+
1 for rs in _state().running.values()
|
|
1238
|
+
if rs.project_id == project_id
|
|
1239
|
+
)
|
|
1240
|
+
slots = cap - running
|
|
1241
|
+
if slots <= 0:
|
|
1242
|
+
return
|
|
1243
|
+
for _ in range(slots):
|
|
1244
|
+
try:
|
|
1245
|
+
await start_orchestrator(project_id)
|
|
1246
|
+
except OrchestratorError as e:
|
|
1247
|
+
if e.status == 409:
|
|
1248
|
+
return # nothing more to do
|
|
1249
|
+
logger.warning(f"auto-continue failed: {e}")
|
|
1250
|
+
return
|
|
1251
|
+
except Exception:
|
|
1252
|
+
logger.exception("auto-continue crashed")
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
# ───────────────────────────── Stop ──────────────────────────────────
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def stop_session(session_id: int) -> bool:
|
|
1259
|
+
"""Cancel the live task. The runner's finalise handler will mark
|
|
1260
|
+
it terminated. Returns True if a live task was cancelled."""
|
|
1261
|
+
rs = _state().running.get(session_id)
|
|
1262
|
+
if rs is None:
|
|
1263
|
+
# Already done; just make sure DB row is consistent.
|
|
1264
|
+
finalize_session(session_id, "terminated", "stop called after exit")
|
|
1265
|
+
return False
|
|
1266
|
+
rs.task.cancel()
|
|
1267
|
+
return True
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def stop_all_for_project(project_id: int) -> int:
|
|
1271
|
+
"""Cancel every live session for this project. Returns count."""
|
|
1272
|
+
n = 0
|
|
1273
|
+
for sid in list(_state().running.keys()):
|
|
1274
|
+
rs = _state().running[sid]
|
|
1275
|
+
if rs.project_id == project_id:
|
|
1276
|
+
stop_session(sid)
|
|
1277
|
+
n += 1
|
|
1278
|
+
return n
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ─────────────────────────── SSE subscriptions ──────────────────────────
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
async def subscribe_session(
|
|
1285
|
+
session_id: int,
|
|
1286
|
+
queue_size: int = 128,
|
|
1287
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
1288
|
+
"""Yield events for one session until it ends. Caller is expected
|
|
1289
|
+
to consume in an async-for; on cancellation we unhook cleanly."""
|
|
1290
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=queue_size)
|
|
1291
|
+
_state().subscribers.setdefault(session_id, []).append(q)
|
|
1292
|
+
try:
|
|
1293
|
+
# Replay last 50 logs first so a late subscriber sees context.
|
|
1294
|
+
with session_scope() as sess:
|
|
1295
|
+
recent = (
|
|
1296
|
+
sess.query(ForgeLog)
|
|
1297
|
+
.filter(ForgeLog.session_id == session_id)
|
|
1298
|
+
.order_by(ForgeLog.id.desc())
|
|
1299
|
+
.limit(50)
|
|
1300
|
+
.all()
|
|
1301
|
+
)
|
|
1302
|
+
for r in reversed(recent):
|
|
1303
|
+
yield {
|
|
1304
|
+
"type": "log",
|
|
1305
|
+
"session_id": session_id,
|
|
1306
|
+
"feature_id": r.feature_id,
|
|
1307
|
+
"message": r.message,
|
|
1308
|
+
"message_type": r.message_type,
|
|
1309
|
+
"log_id": r.id,
|
|
1310
|
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
1311
|
+
"replay": True,
|
|
1312
|
+
}
|
|
1313
|
+
while True:
|
|
1314
|
+
try:
|
|
1315
|
+
ev = await asyncio.wait_for(q.get(), timeout=30)
|
|
1316
|
+
except asyncio.TimeoutError:
|
|
1317
|
+
yield {"type": "heartbeat"}
|
|
1318
|
+
continue
|
|
1319
|
+
yield ev
|
|
1320
|
+
# Terminal event ends the stream.
|
|
1321
|
+
if ev.get("type") == "status" and ev.get("session_status") in (
|
|
1322
|
+
"completed", "failed", "terminated",
|
|
1323
|
+
):
|
|
1324
|
+
return
|
|
1325
|
+
finally:
|
|
1326
|
+
try:
|
|
1327
|
+
_state().subscribers.get(session_id, []).remove(q)
|
|
1328
|
+
except ValueError:
|
|
1329
|
+
pass
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
async def subscribe_global(queue_size: int = 256) -> AsyncIterator[dict[str, Any]]:
|
|
1333
|
+
"""Yield every event from every session. Used by toast notifiers."""
|
|
1334
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=queue_size)
|
|
1335
|
+
_state().global_subscribers.append(q)
|
|
1336
|
+
try:
|
|
1337
|
+
while True:
|
|
1338
|
+
try:
|
|
1339
|
+
ev = await asyncio.wait_for(q.get(), timeout=30)
|
|
1340
|
+
yield ev
|
|
1341
|
+
except asyncio.TimeoutError:
|
|
1342
|
+
yield {"type": "heartbeat"}
|
|
1343
|
+
finally:
|
|
1344
|
+
try:
|
|
1345
|
+
_state().global_subscribers.remove(q)
|
|
1346
|
+
except ValueError:
|
|
1347
|
+
pass
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
# Public surface
|
|
1351
|
+
__all__ = [
|
|
1352
|
+
"OrchestratorError",
|
|
1353
|
+
"find_next_ready_feature",
|
|
1354
|
+
"get_kanban_state",
|
|
1355
|
+
"get_running_for_project",
|
|
1356
|
+
"reconcile_orphaned_sessions",
|
|
1357
|
+
"start_orchestrator",
|
|
1358
|
+
"stop_session",
|
|
1359
|
+
"stop_all_for_project",
|
|
1360
|
+
"finalize_session",
|
|
1361
|
+
"subscribe_session",
|
|
1362
|
+
"subscribe_global",
|
|
1363
|
+
]
|