ltcai 9.9.3 → 9.9.4

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 (81) hide show
  1. package/README.md +43 -38
  2. package/docs/CHANGELOG.md +65 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/retrieval.py +61 -12
  17. package/lattice_brain/graph/retrieval_policy.py +174 -0
  18. package/lattice_brain/graph/retrieval_vector.py +122 -2
  19. package/lattice_brain/portability.py +41 -12
  20. package/lattice_brain/runtime/multi_agent.py +1 -1
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/chat.py +6 -0
  23. package/latticeai/api/chat_agent_http.py +175 -6
  24. package/latticeai/api/chat_intents.py +37 -18
  25. package/latticeai/api/chat_stream.py +76 -2
  26. package/latticeai/api/knowledge_graph.py +35 -1
  27. package/latticeai/core/agent.py +264 -9
  28. package/latticeai/core/enterprise.py +5 -0
  29. package/latticeai/core/file_generation.py +130 -5
  30. package/latticeai/core/legacy_compatibility.py +1 -1
  31. package/latticeai/core/marketplace.py +1 -1
  32. package/latticeai/core/run_store.py +243 -0
  33. package/latticeai/core/workspace_os.py +1 -1
  34. package/latticeai/models/router.py +25 -12
  35. package/latticeai/services/architecture_readiness.py +1 -1
  36. package/latticeai/services/command_center.py +90 -1
  37. package/latticeai/services/folder_watch.py +5 -0
  38. package/latticeai/services/funnel_metrics.py +9 -1
  39. package/latticeai/services/product_readiness.py +1 -1
  40. package/latticeai/services/search_service.py +43 -13
  41. package/package.json +1 -1
  42. package/scripts/bench_agent_smoke.py +410 -0
  43. package/scripts/check_current_release_docs.mjs +1 -1
  44. package/scripts/funnel_soft_gate.py +192 -0
  45. package/src-tauri/Cargo.lock +1 -1
  46. package/src-tauri/Cargo.toml +1 -1
  47. package/src-tauri/tauri.conf.json +1 -1
  48. package/static/app/asset-manifest.json +29 -28
  49. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  50. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-CTnjox7w.js} +2 -2
  51. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  52. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  53. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
  54. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
  55. package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
  56. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
  57. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
  58. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
  59. package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
  60. package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
  61. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  62. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
  63. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
  64. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
  65. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
  66. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  67. package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
  68. package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
  69. package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
  70. package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
  71. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
  72. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
  73. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
  74. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
  75. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  76. package/static/app/index.html +4 -4
  77. package/static/sw.js +1 -1
  78. package/static/app/assets/Act-DmdruVKV.js +0 -1
  79. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  80. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  81. package/static/app/assets/utils-uQYKXNeq.js +0 -7
@@ -13,6 +13,78 @@ class KnowledgeGraphVectorMixin:
13
13
  retrieval/write helpers (e.g. self._vector_text_for_node) through the MRO.
14
14
  """
15
15
 
16
+ # ── embedder fingerprint (review Wave 2.2 — stale_embedder) ──────────────
17
+ # vector_search filters on the CURRENT model/dim, so swapping the embedder
18
+ # silently yields zero vector rows. The fingerprint persisted in graph_meta
19
+ # records which embedder actually built the index; a mismatch is surfaced
20
+ # as the honest ``stale_embedder`` signal instead of a silent degradation.
21
+
22
+ _EMBEDDER_FINGERPRINT_KEY = "embedder_fingerprint"
23
+
24
+ def _embedder_fingerprint_record(
25
+ self, conn: sqlite3.Connection
26
+ ) -> Optional[Dict[str, Any]]:
27
+ """Read the recorded embedder fingerprint from graph_meta (or None)."""
28
+ row = conn.execute(
29
+ "SELECT value FROM graph_meta WHERE key=?",
30
+ (self._EMBEDDER_FINGERPRINT_KEY,),
31
+ ).fetchone()
32
+ if not row:
33
+ return None
34
+ payload = _safe_loads(row["value"])
35
+ if not isinstance(payload, dict) or not payload.get("model_id"):
36
+ return None
37
+ try:
38
+ dim = int(payload.get("dim") or 0)
39
+ except (TypeError, ValueError):
40
+ dim = 0
41
+ return {"model_id": str(payload["model_id"]), "dim": dim}
42
+
43
+ def _write_embedder_fingerprint(self, conn: sqlite3.Connection) -> Dict[str, Any]:
44
+ """Persist the CURRENT embedder identity (same transaction as caller)."""
45
+ fingerprint = {
46
+ "model_id": self._embedding_model.model_id,
47
+ "dim": int(self._embedding_model.dim),
48
+ }
49
+ conn.execute(
50
+ "INSERT OR REPLACE INTO graph_meta(key, value) VALUES (?, ?)",
51
+ (self._EMBEDDER_FINGERPRINT_KEY, _json(fingerprint)),
52
+ )
53
+ return fingerprint
54
+
55
+ def record_embedder_fingerprint(self) -> Dict[str, Any]:
56
+ """Record the current embedder (model_id + dim) as the index builder."""
57
+ with self._connect() as conn:
58
+ return self._write_embedder_fingerprint(conn)
59
+
60
+ def embedder_fingerprint_status(self) -> Dict[str, Any]:
61
+ """Compare the current embedder against the recorded index fingerprint.
62
+
63
+ Returns ``{"current": {model_id, dim}, "recorded": {...} | None,
64
+ "stale_embedder": bool}``. ``stale_embedder`` is True only when a
65
+ fingerprint was recorded AND it differs from the current embedder —
66
+ an unrecorded index (legacy DBs, nothing indexed yet) is honestly
67
+ "unknown", never reported stale. Never raises.
68
+ """
69
+ current = {
70
+ "model_id": self._embedding_model.model_id,
71
+ "dim": int(self._embedding_model.dim),
72
+ }
73
+ recorded: Optional[Dict[str, Any]] = None
74
+ try:
75
+ with self._connect() as conn:
76
+ recorded = self._embedder_fingerprint_record(conn)
77
+ except Exception: # noqa: BLE001 — status must degrade, never raise
78
+ recorded = None
79
+ stale = bool(
80
+ recorded is not None
81
+ and (
82
+ recorded.get("model_id") != current["model_id"]
83
+ or recorded.get("dim") != current["dim"]
84
+ )
85
+ )
86
+ return {"current": current, "recorded": recorded, "stale_embedder": stale}
87
+
16
88
  def _iter_vector_source_items(
17
89
  self,
18
90
  conn: sqlite3.Connection,
@@ -153,6 +225,11 @@ class KnowledgeGraphVectorMixin:
153
225
  indexed += 1
154
226
  else:
155
227
  skipped += 1
228
+ if indexed and self._embedder_fingerprint_record(conn) is None:
229
+ # First successful vector write establishes the fingerprint;
230
+ # later incremental writes never overwrite it (only a full
231
+ # rebuild may flip it after an embedder swap).
232
+ self._write_embedder_fingerprint(conn)
156
233
  summary.update(
157
234
  {
158
235
  "items_total": len(items),
@@ -254,6 +331,10 @@ class KnowledgeGraphVectorMixin:
254
331
  op_id,
255
332
  ),
256
333
  )
334
+ # A successful rebuild (re)establishes which embedder built
335
+ # the index — this is the only path that may flip a recorded
336
+ # fingerprint after an embedder swap.
337
+ self._write_embedder_fingerprint(conn)
257
338
  return {
258
339
  "status": "completed",
259
340
  "operation_id": op_id,
@@ -401,8 +482,10 @@ class KnowledgeGraphVectorMixin:
401
482
  "within_target": float(duration_ms) <= 10_000,
402
483
  }
403
484
  )
485
+ embedder_status = self.embedder_fingerprint_status()
404
486
  return {
405
487
  "status": "ready" if pending == 0 else "needs_reindex",
488
+ "embedder": embedder_status,
406
489
  "storage": {
407
490
  "db_path": str(self.db_path),
408
491
  "backend": "sqlite",
@@ -449,7 +532,11 @@ class KnowledgeGraphVectorMixin:
449
532
  "backlog_reasons": backlog_reasons,
450
533
  "backlog_samples": backlog_samples,
451
534
  "incremental_reindex_recommended": pending > 0,
452
- "full_rebuild_recommended": orphaned_items > 0,
535
+ # A stale embedder means every old-model row must be re-embedded;
536
+ # only a full rebuild (which re-records the fingerprint) heals it.
537
+ "full_rebuild_recommended": bool(
538
+ orphaned_items > 0 or embedder_status["stale_embedder"]
539
+ ),
453
540
  "latency_budget": latency_budget,
454
541
  },
455
542
  "operations": [
@@ -475,7 +562,11 @@ class KnowledgeGraphVectorMixin:
475
562
 
476
563
  Reduces :meth:`index_status` (``pending = missing + stale``) to the
477
564
  fixed contract ``{"status", "pending_items", "total_items", "detail"}``
478
- with ``status`` in ``ready`` / ``pending`` / ``unavailable``.
565
+ with ``status`` in ``ready`` / ``pending`` / ``stale_embedder`` /
566
+ ``unavailable``. ``stale_embedder`` (review Wave 2.2) is reported only
567
+ when the recorded embedder fingerprint differs from the current
568
+ embedder AND rows indexed under the old model still exist — the index
569
+ needs a full rebuild, not an incremental sync.
479
570
 
480
571
  Never raises: environments where the embedding provider or index
481
572
  storage cannot be used report ``"unavailable"`` with the cause in
@@ -492,6 +583,35 @@ class KnowledgeGraphVectorMixin:
492
583
  }
493
584
  pending = int(status.get("pending_items") or 0)
494
585
  total = int(status.get("source_items") or 0)
586
+ embedder = status.get("embedder") or {}
587
+ if embedder.get("stale_embedder"):
588
+ old_model_rows = 0
589
+ try:
590
+ with self._connect() as conn:
591
+ old_model_rows = int(
592
+ conn.execute(
593
+ "SELECT COUNT(*) AS c FROM vector_embeddings "
594
+ "WHERE embedding_model<>? OR embedding_dim<>?",
595
+ (
596
+ self._embedding_model.model_id,
597
+ int(self._embedding_model.dim),
598
+ ),
599
+ ).fetchone()["c"]
600
+ )
601
+ except Exception: # noqa: BLE001 — keep the existing statuses on failure
602
+ old_model_rows = 0
603
+ if old_model_rows > 0:
604
+ recorded = embedder.get("recorded") or {}
605
+ return {
606
+ "status": "stale_embedder",
607
+ "pending_items": pending,
608
+ "total_items": total,
609
+ "detail": (
610
+ f"embedding model changed ({recorded.get('model_id')} → "
611
+ f"{self._embedding_model.model_id}); {old_model_rows} indexed "
612
+ "rows still use the previous model — run a full vector index rebuild"
613
+ ),
614
+ }
495
615
  if pending > 0:
496
616
  return {
497
617
  "status": "pending",
@@ -14,6 +14,7 @@ any cloud service. Two complementary mechanisms, both fully local:
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import logging
17
18
  import os
18
19
  import shutil
19
20
  import sqlite3
@@ -74,9 +75,12 @@ def _checkpoint_sqlite(db_path: Path) -> None:
74
75
 
75
76
 
76
77
  def _restore_sibling(path: Path, backup: Path) -> None:
77
- if backup.exists():
78
+ try:
78
79
  shutil.copy2(backup, path)
79
- else:
80
+ except FileNotFoundError:
81
+ # The backup vanished (or never existed — transient -wal/-shm): the
82
+ # honest reconstruction is "no such sibling", never a crash that
83
+ # masks the error that triggered the rollback.
80
84
  path.unlink(missing_ok=True)
81
85
 
82
86
 
@@ -101,10 +105,22 @@ def _replace_sqlite_atomically(src: Path, dest: Path, backup_dir: Path) -> None:
101
105
  sibling.unlink(missing_ok=True)
102
106
  os.replace(tmp, dest)
103
107
  except Exception:
104
- if tmp.exists():
105
- tmp.unlink()
108
+ # Recovery I/O must never replace the swap error being reported —
109
+ # an exception raised here would mask it (the CI-observed
110
+ # "[Errno 2]" over the real failure). Best-effort restore, then
111
+ # always re-raise the original.
112
+ try:
113
+ tmp.unlink(missing_ok=True)
114
+ except OSError as cleanup_exc:
115
+ logging.warning("restore tmp cleanup failed: %s", cleanup_exc)
106
116
  for sibling in _sqlite_siblings(dest):
107
- _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
117
+ try:
118
+ _restore_sibling(sibling, backups.get(sibling, backup_dir / sibling.name))
119
+ except OSError as rollback_exc:
120
+ logging.warning(
121
+ "restore sibling rollback incomplete for %s: %s",
122
+ sibling, rollback_exc,
123
+ )
108
124
  raise
109
125
 
110
126
 
@@ -127,12 +143,17 @@ def _replace_tree_with_backup(src: Optional[Path], dest: Path, backup_dir: Path)
127
143
  shutil.rmtree(dest)
128
144
  os.replace(staged, dest)
129
145
  except Exception:
130
- if staged.exists():
131
- shutil.rmtree(staged)
132
- if dest.exists():
133
- shutil.rmtree(dest)
134
- if backup.exists():
135
- shutil.copytree(backup, dest)
146
+ # Same masking guard as the sqlite swap: rollback I/O is
147
+ # best-effort and the original failure always propagates.
148
+ try:
149
+ if staged.exists():
150
+ shutil.rmtree(staged)
151
+ if dest.exists():
152
+ shutil.rmtree(dest)
153
+ if backup.exists():
154
+ shutil.copytree(backup, dest)
155
+ except OSError as rollback_exc:
156
+ logging.warning("blob tree rollback incomplete: %s", rollback_exc)
136
157
  raise
137
158
 
138
159
 
@@ -307,7 +328,15 @@ class KGPortabilityService:
307
328
  blob_src = tmp / "blobs"
308
329
  _replace_tree_with_backup(blob_src if blob_src.exists() else None, blob_dest, backup_dir)
309
330
  except Exception:
310
- _rollback_sqlite_from_backup(db_dest, backup_dir)
331
+ # The rollback is best-effort recovery; an I/O slip inside
332
+ # it must never mask the restore failure being reported.
333
+ try:
334
+ _rollback_sqlite_from_backup(db_dest, backup_dir)
335
+ except OSError as rollback_exc:
336
+ logging.warning(
337
+ "pre-restore rollback incomplete (backup kept at %s): %s",
338
+ backup_dir, rollback_exc,
339
+ )
311
340
  raise
312
341
  stats = self._kg.stats()
313
342
  return {
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
48
48
  from ..utils import now_iso as _now
49
49
 
50
50
 
51
- MULTI_AGENT_VERSION = "9.9.3"
51
+ MULTI_AGENT_VERSION = "9.9.4"
52
52
 
53
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
54
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.3"
3
+ __version__ = "9.9.4"
@@ -45,6 +45,7 @@ from latticeai.api.chat_helpers import (
45
45
  workspace_scope_from_request,
46
46
  )
47
47
  from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
48
+ from latticeai.core.run_store import AgentRunStore
48
49
  from latticeai.api.chat_intents import ChatIntentController
49
50
  from latticeai.api.chat_stream import stream_chat
50
51
  from latticeai.services.app_context import AppContext
@@ -191,6 +192,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
191
192
  agent_root=AGENT_ROOT,
192
193
  ensure_agent_root=ensure_agent_root,
193
194
  funnel_metrics=context.funnel_metrics,
195
+ run_store=(
196
+ AgentRunStore(Path(context.data_dir) / "agent_runs")
197
+ if context.data_dir
198
+ else None
199
+ ),
194
200
  )
195
201
  intent_controller = ChatIntentController(
196
202
  model_router=model_router,
@@ -11,7 +11,7 @@ import threading
11
11
  import time
12
12
  from datetime import datetime, timedelta, timezone
13
13
  from pathlib import Path
14
- from typing import Any, Dict
14
+ from typing import Any, Dict, Optional
15
15
 
16
16
  from fastapi import APIRouter, HTTPException, Request
17
17
 
@@ -19,6 +19,11 @@ from lattice_brain.runtime.hooks import dispatch_tool
19
19
  from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
20
20
  from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
21
21
  from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
22
+ from latticeai.core.run_store import (
23
+ AgentRunStore,
24
+ hash_approval_token,
25
+ restore_run_context,
26
+ )
22
27
  from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
23
28
 
24
29
 
@@ -42,6 +47,7 @@ class AgentHTTPController:
42
47
  agent_root: Path,
43
48
  ensure_agent_root: Any,
44
49
  funnel_metrics: Any = None,
50
+ run_store: Any = None,
45
51
  ) -> None:
46
52
  self.runtime = runtime
47
53
  self.model_router = model_router
@@ -68,6 +74,16 @@ class AgentHTTPController:
68
74
  # explicit human-in-loop context flow above).
69
75
  self._approvals: Dict[str, Dict[str, Any]] = {}
70
76
  self._approval_ttl_seconds = 10 * 60
77
+ # Durable side of the approval loop (review Wave 0.1): paused runs are
78
+ # mirrored to disk so a valid token still resumes after a restart or
79
+ # from another worker process. In-memory stays the fast path.
80
+ self.run_store = run_store if run_store is not None else AgentRunStore(
81
+ Path(base_dir) / "data" / "agent_runs"
82
+ )
83
+ try:
84
+ self.run_store.sweep_expired()
85
+ except Exception as exc: # noqa: BLE001 — hygiene must not break startup
86
+ logging.warning("agent run store sweep failed: %s", exc)
71
87
  self._background_tasks: set[asyncio.Task] = set()
72
88
 
73
89
  def register_routes(self, router: APIRouter) -> None:
@@ -83,6 +99,10 @@ class AgentHTTPController:
83
99
  async def agent_resume(req: AgentResumeRequest, request: Request):
84
100
  return await self.resume(req, request)
85
101
 
102
+ @router.get("/agent/approvals")
103
+ async def agent_approvals(request: Request):
104
+ return self.pending_approvals(request)
105
+
86
106
  def _schedule_background_task(self, coro: Any) -> None:
87
107
  task = asyncio.create_task(coro)
88
108
  self._background_tasks.add(task)
@@ -180,8 +200,17 @@ class AgentHTTPController:
180
200
  "results": results,
181
201
  }
182
202
 
183
- async def agent(self, req: AgentRequest, request: Request) -> Dict[str, Any]:
184
- """Plan and execute a natural-language local agent run."""
203
+ async def agent(
204
+ self,
205
+ req: AgentRequest,
206
+ request: Request,
207
+ on_step: Any = None,
208
+ ) -> Dict[str, Any]:
209
+ """Plan and execute a natural-language local agent run.
210
+
211
+ ``on_step`` (optional) is a per-run step observer — the live SSE
212
+ route attaches one so the client sees progress while EXECUTING.
213
+ """
185
214
  current_user = self.require_user(request)
186
215
  self.enforce_rate_limit(current_user, "agent")
187
216
  effective_email = self.authenticated_identity(current_user, req.user_email)
@@ -207,6 +236,8 @@ class AgentHTTPController:
207
236
  max_steps = max(1, min(req.max_steps, 50))
208
237
  max_retry = 3
209
238
  ctx = AgentRunContext()
239
+ if on_step is not None:
240
+ ctx.on_step = on_step
210
241
  ctx.executing_model = req.executing_model
211
242
  ctx.reviewing_model = req.reviewing_model
212
243
  ctx.state = AgentState.PLANNING
@@ -289,6 +320,26 @@ class AgentHTTPController:
289
320
  "expires_monotonic": now_monotonic + self._approval_ttl_seconds,
290
321
  "expires_at": expires_at,
291
322
  }
323
+ # Durable mirror: a restart between pause and resume must not orphan
324
+ # the run. Best-effort — the in-memory pause still answers on failure.
325
+ try:
326
+ self.run_store.save(
327
+ run_id,
328
+ ctx=ctx,
329
+ req_payload=req.model_dump(),
330
+ language_hint=language_hint,
331
+ user=current_user,
332
+ token=approval_token,
333
+ expires_epoch=time.time() + self._approval_ttl_seconds,
334
+ expires_at=expires_at,
335
+ )
336
+ except Exception as exc: # noqa: BLE001
337
+ logging.warning("agent run store persist failed: %s", exc)
338
+ if self.funnel_metrics is not None:
339
+ try:
340
+ self.funnel_metrics.increment("approval_pauses")
341
+ except Exception as exc: # noqa: BLE001 — advisory only
342
+ logging.warning("funnel metrics increment failed: %s", exc)
292
343
  ctx.state_history.append(AgentState.WAITING_APPROVAL.value)
293
344
  message = (
294
345
  "이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
@@ -318,6 +369,101 @@ class AgentHTTPController:
318
369
  for run_id, entry in list(self._approvals.items()):
319
370
  if now_monotonic >= entry["expires_monotonic"]:
320
371
  self._approvals.pop(run_id, None)
372
+ try:
373
+ self.run_store.delete(run_id)
374
+ except Exception: # noqa: BLE001 — hygiene only
375
+ pass
376
+
377
+ def pending_approvals(self, request: Request) -> Dict[str, Any]:
378
+ """Unexpired paused runs for the current user (memory ∪ disk).
379
+
380
+ Lets the UI re-surface an approval card after a reload/restart instead
381
+ of the run silently vanishing.
382
+ """
383
+ current_user = self.require_user(request)
384
+ now_monotonic = time.monotonic()
385
+ pending: Dict[str, Dict[str, Any]] = {}
386
+ with self._pending_lock:
387
+ for run_id, entry in self._approvals.items():
388
+ if entry["user"] != current_user:
389
+ continue
390
+ if now_monotonic >= entry["expires_monotonic"]:
391
+ continue
392
+ plan = getattr(entry["ctx"], "plan", {}) or {}
393
+ pending[run_id] = {
394
+ "run_id": run_id,
395
+ "goal": str(plan.get("goal") or "")[:200],
396
+ "expires_at": entry["expires_at"],
397
+ }
398
+ try:
399
+ for summary in self.run_store.pending_summaries(current_user):
400
+ run_id = summary.get("run_id")
401
+ if run_id and run_id not in pending:
402
+ pending[run_id] = {
403
+ "run_id": run_id,
404
+ "goal": summary.get("goal") or "",
405
+ "expires_at": summary.get("expires_at"),
406
+ }
407
+ except Exception as exc: # noqa: BLE001 — disk listing is best-effort
408
+ logging.warning("agent run store listing failed: %s", exc)
409
+ return {"pending": sorted(pending.values(), key=lambda p: p["run_id"])}
410
+
411
+ def _restore_persisted_approval(self, run_id: str) -> Optional[Dict[str, Any]]:
412
+ """Rebuild an approval entry from disk after a restart.
413
+
414
+ Returns an entry shaped like the in-memory ones but carrying
415
+ ``token_hash`` instead of the plaintext token. Expired records are
416
+ deleted and reported via the same 410 contract as the memory path.
417
+ """
418
+ try:
419
+ record = self.run_store.load(run_id)
420
+ except Exception as exc: # noqa: BLE001 — load error == not found
421
+ logging.warning("agent run store load failed: %s", exc)
422
+ return None
423
+ if record is None:
424
+ return None
425
+ req_payload = record.get("req") or {}
426
+ if time.time() >= float(record.get("expires_epoch") or 0):
427
+ try:
428
+ self.run_store.delete(run_id)
429
+ except Exception: # noqa: BLE001
430
+ pass
431
+ raise HTTPException(
432
+ status_code=410,
433
+ detail={
434
+ "error": "approval_expired",
435
+ "message": "Approval token expired. Start a new request.",
436
+ "replan": {"message": str(req_payload.get("message") or "")},
437
+ },
438
+ )
439
+ try:
440
+ restored_req = AgentRequest(**req_payload)
441
+ except Exception as exc: # noqa: BLE001 — unreconstructable == not found
442
+ logging.warning("agent run store request restore failed: %s", exc)
443
+ return None
444
+ remaining = max(1.0, float(record["expires_epoch"]) - time.time())
445
+ return {
446
+ "ctx": restore_run_context(record.get("ctx") or {}),
447
+ "req": restored_req,
448
+ "language_hint": str(record.get("language_hint") or "English"),
449
+ "user": record.get("user"),
450
+ "token_hash": str(record.get("token_hash") or ""),
451
+ "expires_monotonic": time.monotonic() + remaining,
452
+ "expires_at": record.get("expires_at"),
453
+ }
454
+
455
+ @staticmethod
456
+ def _token_matches(entry: Dict[str, Any], supplied: str) -> bool:
457
+ """Constant-time token check for both plaintext and hashed entries."""
458
+ if not supplied:
459
+ return False
460
+ stored = entry.get("token")
461
+ if stored is not None:
462
+ return secrets.compare_digest(str(stored), supplied)
463
+ stored_hash = str(entry.get("token_hash") or "")
464
+ return bool(stored_hash) and secrets.compare_digest(
465
+ stored_hash, hash_approval_token(supplied)
466
+ )
321
467
 
322
468
  async def _finish(
323
469
  self,
@@ -468,6 +614,10 @@ class AgentHTTPController:
468
614
  now_monotonic = time.monotonic()
469
615
  with self._pending_lock:
470
616
  entry = self._approvals.get(req.run_id or "")
617
+ if entry is None:
618
+ # Restart survival (review Wave 0.1): the in-memory dict is
619
+ # gone but the durable record may still be valid.
620
+ entry = self._restore_persisted_approval(req.run_id or "")
471
621
  if entry is None:
472
622
  raise HTTPException(
473
623
  status_code=404,
@@ -480,24 +630,43 @@ class AgentHTTPController:
480
630
  )
481
631
  if now_monotonic >= entry["expires_monotonic"]:
482
632
  self._approvals.pop(req.run_id, None)
633
+ try:
634
+ self.run_store.delete(req.run_id or "")
635
+ except Exception: # noqa: BLE001
636
+ pass
483
637
  raise HTTPException(
484
638
  status_code=410,
485
- detail="Approval token expired. Start a new request.",
639
+ detail={
640
+ "error": "approval_expired",
641
+ "message": "Approval token expired. Start a new request.",
642
+ "replan": {"message": getattr(entry.get("req"), "message", "")},
643
+ },
486
644
  )
487
- supplied = req.approval_token or ""
488
- if not supplied or not secrets.compare_digest(entry["token"], supplied):
645
+ if not self._token_matches(entry, req.approval_token or ""):
489
646
  raise HTTPException(
490
647
  status_code=403,
491
648
  detail="Invalid approval token for this run.",
492
649
  )
493
650
  # Token validated — the pending run is consumed either way.
494
651
  self._approvals.pop(req.run_id, None)
652
+ try:
653
+ self.run_store.delete(req.run_id or "")
654
+ except Exception as exc: # noqa: BLE001 — consumption must proceed
655
+ logging.warning("agent run store delete failed: %s", exc)
495
656
  self._purge_expired_approvals_locked(now_monotonic)
496
657
 
497
658
  ctx: AgentRunContext = entry["ctx"]
498
659
  original_request: AgentRequest = entry["req"]
499
660
  language_hint: str = entry["language_hint"]
500
661
 
662
+ if self.funnel_metrics is not None:
663
+ # approval_resume_rate = resumes / pauses (review §4.3): counted on
664
+ # every token-valid resume decision, approve and deny alike.
665
+ try:
666
+ self.funnel_metrics.increment("approval_resumes")
667
+ except Exception as exc: # noqa: BLE001 — advisory only
668
+ logging.warning("funnel metrics increment failed: %s", exc)
669
+
501
670
  approved = req.approve if req.approve is not None else req.approved
502
671
  if not approved:
503
672
  message = "사용자가 계획을 취소했습니다."
@@ -17,7 +17,11 @@ from latticeai.api.chat_helpers import (
17
17
  format_network_status,
18
18
  inline_file_action_content,
19
19
  )
20
- from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
20
+ from latticeai.api.chat_stream import (
21
+ agent_live_stream,
22
+ agent_payload_stream,
23
+ single_answer_response,
24
+ )
21
25
  from latticeai.core.agent import AgentState
22
26
  from latticeai.core.file_generation import (
23
27
  PREVIEWABLE_EXTENSIONS,
@@ -596,31 +600,46 @@ class ChatIntentController:
596
600
  user_nickname=req.user_nickname,
597
601
  workspace_id=workspace_id,
598
602
  )
599
- result = await self.agent_controller.agent(agent_request, request)
600
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
601
- # UX funnel (backlog #16): a file-intent request that the agent loop
602
- # finished without producing a single artifact is the "code-only"
603
- # failure mode the >95% real-file goal watches. Paused/failed runs
604
- # count neither way they did not answer with code instead of files.
605
- delivered = bool(result.get("created_files") or result.get("artifacts"))
606
- if delivered:
607
- self._funnel_increment("real_file_delivered")
608
- elif str(result.get("status") or "") == "ok":
609
- self._funnel_increment("code_only_responses")
610
- self.notify("user", req.message, req.source)
611
- self.notify("assistant", answer, req.source)
612
- result["routed_to_agent"] = True
603
+
604
+ def finalize(result: Dict) -> str:
605
+ """History/funnel side effects once the run has a terminal payload."""
606
+ answer = str(result.get("response") or "파일 작업을 처리했습니다.")
607
+ # UX funnel (backlog #16): a file-intent request that the agent loop
608
+ # finished without producing a single artifact is the "code-only"
609
+ # failure mode the >95% real-file goal watches. Paused/failed runs
610
+ # count neither way — they did not answer with code instead of files.
611
+ delivered = bool(result.get("created_files") or result.get("artifacts"))
612
+ if delivered:
613
+ self._funnel_increment("real_file_delivered")
614
+ elif str(result.get("status") or "") == "ok":
615
+ self._funnel_increment("code_only_responses")
616
+ self.notify("user", req.message, req.source)
617
+ self.notify("assistant", answer, req.source)
618
+ result["routed_to_agent"] = True
619
+ return answer
620
+
613
621
  if req.stream:
622
+ # Live loop visibility (review Wave 1.1): run the agent inside the
623
+ # SSE generator so step events stream while EXECUTING; the final
624
+ # frames keep the exact historical payload shape.
625
+ async def start(observer):
626
+ return await self.agent_controller.agent(
627
+ agent_request, request, on_step=observer
628
+ )
629
+
614
630
  return StreamingResponse(
615
- agent_payload_stream(
616
- answer,
617
- result,
631
+ agent_live_stream(
632
+ start,
618
633
  router=self.router,
619
634
  model_id=model_id,
635
+ finalize=finalize,
620
636
  ),
621
637
  media_type="text/event-stream",
622
638
  headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
623
639
  )
640
+
641
+ result = await self.agent_controller.agent(agent_request, request)
642
+ finalize(result)
624
643
  return JSONResponse(content=result)
625
644
 
626
645