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.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -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
+ ]