ltcai 9.9.4 → 9.9.5

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 (64) hide show
  1. package/README.md +38 -42
  2. package/docs/CHANGELOG.md +35 -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 +10 -4
  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/rerank.py +160 -0
  13. package/lattice_brain/graph/retrieval.py +28 -442
  14. package/lattice_brain/graph/retrieval_reads.py +459 -0
  15. package/lattice_brain/graph/store.py +2 -0
  16. package/lattice_brain/quality.py +26 -6
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/chat_agent_http.py +87 -46
  20. package/latticeai/api/chat_contracts.py +1 -1
  21. package/latticeai/core/agent.py +156 -18
  22. package/latticeai/core/legacy_compatibility.py +1 -1
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/run_store.py +2 -0
  25. package/latticeai/core/workspace_os.py +1 -1
  26. package/latticeai/integrations/telegram_bot.py +82 -23
  27. package/latticeai/services/architecture_readiness.py +1 -1
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/latticeai/services/tool_dispatch.py +56 -0
  30. package/package.json +2 -1
  31. package/scripts/check_current_release_docs.mjs +1 -1
  32. package/scripts/run_sidecar_e2e.mjs +159 -0
  33. package/src-tauri/Cargo.lock +1 -1
  34. package/src-tauri/Cargo.toml +1 -1
  35. package/src-tauri/tauri.conf.json +1 -1
  36. package/static/app/asset-manifest.json +28 -28
  37. package/static/app/assets/{Act-BCmTU0E2.js → Act-DmxfbqBp.js} +1 -1
  38. package/static/app/assets/{Brain-CTnjox7w.js → Brain-DK_zKCda.js} +1 -1
  39. package/static/app/assets/{BrainHome-BJ3sFNX0.js → BrainHome-CCE_0hni.js} +1 -1
  40. package/static/app/assets/{BrainSignals-C52lwZVD.js → BrainSignals-r_J68_zh.js} +1 -1
  41. package/static/app/assets/{Capture-B6vBhFa3.js → Capture-CvxwzpJZ.js} +1 -1
  42. package/static/app/assets/{CommandPalette-90u9FWFH.js → CommandPalette-CVLiFIQC.js} +1 -1
  43. package/static/app/assets/{Library-pKCK0_tk.js → Library-4As9VUvy.js} +1 -1
  44. package/static/app/assets/{LivingBrain-Jlf2wFqI.js → LivingBrain-BVWk58MW.js} +1 -1
  45. package/static/app/assets/{ProductFlow-yg1fKP1P.js → ProductFlow-B-Tz0Um4.js} +1 -1
  46. package/static/app/assets/{ReviewCard-DWvD7n9h.js → ReviewCard-Va3fPQuj.js} +1 -1
  47. package/static/app/assets/{System-BAEuHqNY.js → System-DSR2zKWd.js} +1 -1
  48. package/static/app/assets/{bot-nB_buEZD.js → bot-VmP0kkeA.js} +1 -1
  49. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause-T9POo4qy.js} +1 -1
  50. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-Bz5iTD0p.js} +1 -1
  51. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-CJhjRtNq.js} +1 -1
  52. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-B2K_22VI.js} +1 -1
  53. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-CjJtYqHf.js} +1 -1
  54. package/static/app/assets/{index-DrmOCySv.js → index-CEu0Wqjl.js} +3 -3
  55. package/static/app/assets/{input-gtVCg-ll.js → input-CSi6OBJ9.js} +1 -1
  56. package/static/app/assets/{navigation-Bot0hvuv.js → navigation-BPQqEQwr.js} +1 -1
  57. package/static/app/assets/{network-jE42eKfT.js → network-Ck1nqc-v.js} +1 -1
  58. package/static/app/assets/{primitives-CX2Komon.js → primitives-B2f9N7Dh.js} +1 -1
  59. package/static/app/assets/{shield-alert-BftATuAA.js → shield-alert-yA_Y8lOB.js} +1 -1
  60. package/static/app/assets/{textarea-CiMJfOSI.js → textarea-C4wM0D-Z.js} +1 -1
  61. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  62. package/static/app/assets/{utils-SJUNVOj5.js → utils-BqoznzGP.js} +1 -1
  63. package/static/app/index.html +3 -3
  64. package/static/sw.js +1 -1
@@ -65,9 +65,7 @@ class AgentHTTPController:
65
65
  self.agent_root = Path(agent_root)
66
66
  self.ensure_agent_root = ensure_agent_root
67
67
  self.funnel_metrics = funnel_metrics
68
- self._pending: Dict[str, tuple] = {}
69
68
  self._pending_lock = threading.Lock()
70
- self._pending_ttl_seconds = 15 * 60
71
69
  # awaiting_approval runs: run_id -> paused run state + approval token.
72
70
  # Fail-closed: governed steps only ever execute after resume presents
73
71
  # the matching, unexpired token for this run (or via the legacy
@@ -250,33 +248,29 @@ class AgentHTTPController:
250
248
  model_id=req.planning_model,
251
249
  )
252
250
 
251
+ requirements_probe = getattr(self.runtime, "approval_requirements", None)
252
+
253
253
  if req.human_in_loop:
254
- context_id = secrets.token_urlsafe(16)
255
- with self._pending_lock:
256
- self._pending[context_id] = (
257
- ctx,
258
- req,
259
- language_hint,
260
- current_user,
261
- time.monotonic(),
262
- )
263
- return {
264
- "status": "waiting_approval",
265
- "context_id": context_id,
266
- "plan": ctx.plan,
267
- "steps": ctx.transcript,
268
- "state_history": ctx.state_history,
269
- "planning_model": req.planning_model or self.model_router.current_model_id,
270
- "executing_model": req.executing_model or self.model_router.current_model_id,
271
- "reviewing_model": req.reviewing_model or self.model_router.current_model_id,
272
- "loop": ctx.trace.summary(),
273
- }
254
+ # Deprecated explicit pause (L1 unification, 9.9.5): the legacy
255
+ # ``human_in_loop`` flow now rides the same durable approval
256
+ # store as ``awaiting_approval`` — one pause path, one resume
257
+ # path, restart-safe. The legacy wire contract is preserved:
258
+ # ``status="waiting_approval"`` + ``context_id`` (token-less,
259
+ # same-user resume).
260
+ requirements = (
261
+ requirements_probe(ctx)
262
+ if callable(requirements_probe)
263
+ else {"requires_approval": True, "non_auto_steps": [], "plan_summary": ""}
264
+ )
265
+ return self._pause_for_approval(
266
+ ctx, req, language_hint, current_user, requirements,
267
+ legacy_context=True,
268
+ )
274
269
 
275
270
  # Interactive approval: when the plan needs human approval, pause the
276
271
  # run as awaiting_approval (short-TTL token) instead of failing it.
277
272
  # ``getattr`` keeps injected fake runtimes without the preview method
278
273
  # on the historical fail-closed path.
279
- requirements_probe = getattr(self.runtime, "approval_requirements", None)
280
274
  if callable(requirements_probe):
281
275
  requirements = requirements_probe(ctx)
282
276
  if requirements.get("requires_approval"):
@@ -301,8 +295,16 @@ class AgentHTTPController:
301
295
  language_hint: str,
302
296
  current_user: str,
303
297
  requirements: Dict[str, Any],
298
+ *,
299
+ legacy_context: bool = False,
304
300
  ) -> Dict[str, Any]:
305
- """Park a plan that needs approval and hand the user a resume token."""
301
+ """Park a plan that needs approval and hand the user a resume token.
302
+
303
+ ``legacy_context=True`` marks a deprecated ``human_in_loop`` pause:
304
+ same durable storage, but the response speaks the historical
305
+ ``waiting_approval``/``context_id`` contract and resume may present
306
+ the ``context_id`` instead of the approval token (same user only).
307
+ """
306
308
  run_id = secrets.token_urlsafe(16)
307
309
  approval_token = secrets.token_urlsafe(32)
308
310
  now_monotonic = time.monotonic()
@@ -319,6 +321,7 @@ class AgentHTTPController:
319
321
  "token": approval_token,
320
322
  "expires_monotonic": now_monotonic + self._approval_ttl_seconds,
321
323
  "expires_at": expires_at,
324
+ "legacy_context": legacy_context,
322
325
  }
323
326
  # Durable mirror: a restart between pause and resume must not orphan
324
327
  # the run. Best-effort — the in-memory pause still answers on failure.
@@ -332,6 +335,7 @@ class AgentHTTPController:
332
335
  token=approval_token,
333
336
  expires_epoch=time.time() + self._approval_ttl_seconds,
334
337
  expires_at=expires_at,
338
+ legacy_context=legacy_context,
335
339
  )
336
340
  except Exception as exc: # noqa: BLE001
337
341
  logging.warning("agent run store persist failed: %s", exc)
@@ -345,8 +349,8 @@ class AgentHTTPController:
345
349
  "이 작업에는 승인이 필요한 단계가 있어 실행을 잠시 멈췄습니다. "
346
350
  "계획을 확인한 뒤 승인하면 이어서 실행합니다."
347
351
  )
348
- return {
349
- "status": "awaiting_approval",
352
+ payload = {
353
+ "status": "waiting_approval" if legacy_context else "awaiting_approval",
350
354
  "run_id": run_id,
351
355
  "approval": {
352
356
  "token": approval_token,
@@ -364,6 +368,10 @@ class AgentHTTPController:
364
368
  "reviewing_model": req.reviewing_model or self.model_router.current_model_id,
365
369
  "loop": ctx.trace.summary(),
366
370
  }
371
+ if legacy_context:
372
+ # Historical wire field — the run id doubles as the context id.
373
+ payload["context_id"] = run_id
374
+ return payload
367
375
 
368
376
  def _purge_expired_approvals_locked(self, now_monotonic: float) -> None:
369
377
  for run_id, entry in list(self._approvals.items()):
@@ -450,6 +458,7 @@ class AgentHTTPController:
450
458
  "token_hash": str(record.get("token_hash") or ""),
451
459
  "expires_monotonic": time.monotonic() + remaining,
452
460
  "expires_at": record.get("expires_at"),
461
+ "legacy_context": bool(record.get("legacy_context")),
453
462
  }
454
463
 
455
464
  @staticmethod
@@ -565,31 +574,63 @@ class AgentHTTPController:
565
574
  status_code=400,
566
575
  detail="run_id (with approval_token) or context_id is required.",
567
576
  )
577
+ # Deprecated context_id resume (L1 unification, 9.9.5): the legacy
578
+ # pause lives in the same durable approval store now. Token-less
579
+ # resume stays allowed for it — bound to the pausing user, and only
580
+ # for entries that were created through the legacy pause.
581
+ now_monotonic = time.monotonic()
568
582
  with self._pending_lock:
569
- now = time.monotonic()
570
- for context_id, pending in list(self._pending.items()):
571
- if now - pending[4] >= self._pending_ttl_seconds:
572
- self._pending.pop(context_id, None)
573
- entry = self._pending.get(req.context_id)
574
- if entry and entry[3] == current_user:
575
- self._pending.pop(req.context_id, None)
576
- if not entry:
577
- raise HTTPException(
578
- status_code=404,
579
- detail="Agent context not found or expired. Start a new request.",
580
- )
583
+ entry = self._approvals.get(req.context_id)
584
+ if entry is None:
585
+ try:
586
+ entry = self._restore_persisted_approval(req.context_id)
587
+ except HTTPException as exc:
588
+ if exc.status_code == 410:
589
+ # Legacy contract never had a distinct expiry answer.
590
+ raise HTTPException(
591
+ status_code=404,
592
+ detail="Agent context not found or expired. Start a new request.",
593
+ ) from exc
594
+ raise
595
+ if entry is None or not entry.get("legacy_context"):
596
+ raise HTTPException(
597
+ status_code=404,
598
+ detail="Agent context not found or expired. Start a new request.",
599
+ )
600
+ if entry["user"] != current_user:
601
+ raise HTTPException(
602
+ status_code=403,
603
+ detail="Agent context belongs to another user.",
604
+ )
605
+ if now_monotonic >= entry["expires_monotonic"]:
606
+ self._approvals.pop(req.context_id, None)
607
+ try:
608
+ self.run_store.delete(req.context_id)
609
+ except Exception: # noqa: BLE001
610
+ pass
611
+ raise HTTPException(
612
+ status_code=404,
613
+ detail="Agent context not found or expired. Start a new request.",
614
+ )
615
+ self._approvals.pop(req.context_id, None)
616
+ try:
617
+ self.run_store.delete(req.context_id)
618
+ except Exception as exc: # noqa: BLE001
619
+ logging.warning("agent run store delete failed: %s", exc)
581
620
 
582
- ctx, original_request, language_hint, original_user, _created_at = entry
583
- if original_user != current_user:
584
- raise HTTPException(
585
- status_code=403,
586
- detail="Agent context belongs to another user.",
587
- )
621
+ ctx = entry["ctx"]
622
+ original_request = entry["req"]
623
+ language_hint = entry["language_hint"]
588
624
  if not req.approved:
589
625
  return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
590
626
  if req.modified_plan:
591
- ctx.plan = req.modified_plan
592
- ctx.transcript[-1].update(ctx.plan)
627
+ plan, plan_fixes = normalize_plan(req.modified_plan, original_request.message)
628
+ ctx.plan = plan
629
+ ctx.transcript.append({
630
+ "state": AgentState.WAITING_APPROVAL.value,
631
+ "edited_plan": True,
632
+ **({"plan_fixes": plan_fixes} if plan_fixes else {}),
633
+ })
593
634
  ctx.executing_model = req.executing_model or ctx.executing_model
594
635
  ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
595
636
  # The authenticated owner of this pending context explicitly approved
@@ -45,7 +45,7 @@ class AgentResumeRequest(BaseModel):
45
45
  modified_plan: Optional[dict] = None
46
46
  executing_model: Optional[str] = None
47
47
  reviewing_model: Optional[str] = None
48
- # awaiting_approval flow (v9.10): run_id + short-TTL approval token.
48
+ # awaiting_approval flow (v9.9.x): run_id + short-TTL approval token.
49
49
  run_id: Optional[str] = None
50
50
  approval_token: Optional[str] = None
51
51
  approve: Optional[bool] = None
@@ -340,6 +340,77 @@ def compact_transcript(
340
340
  return summarized
341
341
 
342
342
 
343
+ def files_written(
344
+ transcript: List[Dict[str, Any]],
345
+ file_create_actions: FrozenSet[str],
346
+ ) -> List[str]:
347
+ """Ordered unique paths of files this run successfully wrote (review L5).
348
+
349
+ Later executor steps get this as explicit context, so "만들고 이어서
350
+ 설명해" multi-step work sees its own output instead of a stale
351
+ workspace picture.
352
+ """
353
+ seen: List[str] = []
354
+ for step in transcript:
355
+ if step.get("state") != AgentState.EXECUTING.value:
356
+ continue
357
+ if step.get("action") not in file_create_actions:
358
+ continue
359
+ if not isinstance(step.get("result"), dict):
360
+ continue
361
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
362
+ if path and str(path) not in seen:
363
+ seen.append(str(path))
364
+ return seen
365
+
366
+
367
+ def artifact_checklist(
368
+ transcript: List[Dict[str, Any]],
369
+ file_create_actions: FrozenSet[str],
370
+ ) -> List[Dict[str, Any]]:
371
+ """Deterministic artifact facts for the critic (review L4).
372
+
373
+ The critic previously judged file work from prose alone; this surfaces
374
+ the sanitize/repair honesty flags per written file so a repaired
375
+ placeholder can never pass as a fulfilled request unchecked.
376
+ """
377
+ checklist: List[Dict[str, Any]] = []
378
+ for step in transcript:
379
+ if step.get("state") != AgentState.EXECUTING.value:
380
+ continue
381
+ if step.get("action") not in file_create_actions:
382
+ continue
383
+ if not isinstance(step.get("result"), dict):
384
+ continue
385
+ path = step["result"].get("path") or (step.get("args") or {}).get("path")
386
+ if not path:
387
+ continue
388
+ sanitize_meta = step.get("content_sanitize") or {}
389
+ checklist.append({
390
+ "path": str(path),
391
+ "sanitized": bool(sanitize_meta.get("sanitized")),
392
+ "repaired": bool(sanitize_meta.get("repaired")),
393
+ })
394
+ return checklist
395
+
396
+
397
+ def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
398
+ lines = []
399
+ for item in checklist:
400
+ state = (
401
+ "auto-REPAIRED scaffold" if item["repaired"]
402
+ else ("sanitized model output" if item["sanitized"] else "written as produced")
403
+ )
404
+ lines.append(f"- {item['path']}: {state}")
405
+ return (
406
+ "Artifact checklist (deterministic, from the transcript):\n"
407
+ + "\n".join(lines)
408
+ + "\nVerify each artifact actually fulfills the user's request. An "
409
+ "auto-repaired scaffold is NOT completion unless its content "
410
+ "satisfies what was asked."
411
+ )
412
+
413
+
343
414
  @dataclass(frozen=True)
344
415
  class TranscriptBudget:
345
416
  """Executor/critic prompt shaping caps (review Wave 0.3).
@@ -450,6 +521,16 @@ class AgentDeps:
450
521
  # state machine does not shell out directly. Tests can pass a recorder.
451
522
  rollback_file: Optional[Callable[[str], Dict[str, Any]]] = None
452
523
 
524
+ # ── snapshot rollback ports (optional, review L7) ────────────────
525
+ # git-only rollback left non-git workspaces and newly created files
526
+ # unrecoverable. ``snapshot_file(path)`` captures pre-write state
527
+ # ({"existed", "content", "too_large"}) before a file-create action;
528
+ # ``restore_snapshot(path, content)`` restores it (content=None deletes
529
+ # a file the run created). Both are production-wired with workspace
530
+ # path safety; tests pass recorders.
531
+ snapshot_file: Optional[Callable[[str], Dict[str, Any]]] = None
532
+ restore_snapshot: Optional[Callable[[str, Optional[str]], Dict[str, Any]]] = None
533
+
453
534
  # ── lifecycle hooks port (optional) ──────────────────────────────
454
535
  # When present, every tool execution fires the shared pre_tool/post_tool
455
536
  # lifecycle, so the agent tool path no longer bypasses hooks.
@@ -791,11 +872,18 @@ class SingleAgentRuntime:
791
872
  window=budget.window,
792
873
  result_chars=budget.result_chars,
793
874
  )
875
+ # Mid-run workspace awareness (review L5): later steps must see what
876
+ # this run already produced instead of a stale workspace picture.
877
+ written = files_written(ctx.transcript, d.file_create_actions)
878
+ written_hint = (
879
+ "\n\nFiles written by this run so far (they exist in the workspace now):\n"
880
+ + "\n".join(f"- {path}" for path in written)
881
+ ) if written else ""
794
882
  return (
795
883
  f"{d.executor_prompt}\n\n"
796
884
  f"[LANGUAGE HINT: {lang_hint}]\n"
797
885
  f"Workspace root: {d.agent_root}\n\n"
798
- f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
886
+ f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
799
887
  f"Recent conversation:\n{recent_conversation}\n\n"
800
888
  f"User request: {req.message}{corrections_hint}\n\n"
801
889
  f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
@@ -961,6 +1049,22 @@ class SingleAgentRuntime:
961
1049
  if s.get("state") == AgentState.EXECUTING.value
962
1050
  and s.get("action") not in (None, "final", "parse_error")
963
1051
  )
1052
+ if (
1053
+ name in d.file_create_actions
1054
+ and d.snapshot_file is not None
1055
+ and args.get("path")
1056
+ ):
1057
+ # Pre-write snapshot (review L7): the first capture per path is
1058
+ # the true pre-run state — later writes to the same path must
1059
+ # not overwrite it. Best-effort: a snapshot failure never
1060
+ # blocks the write, it only narrows rollback options.
1061
+ path_str = str(args["path"])
1062
+ if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
1063
+ try:
1064
+ pre = d.snapshot_file(path_str)
1065
+ ctx.rollback_log.append({"path": path_str, **(pre or {})})
1066
+ except Exception as exc: # noqa: BLE001
1067
+ logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
964
1068
  try:
965
1069
  d.check_role(name, current_user)
966
1070
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -1025,11 +1129,17 @@ class SingleAgentRuntime:
1025
1129
  verify_transcript = _truncate_strings(
1026
1130
  ctx.transcript, self.transcript_budget.verify_chars
1027
1131
  )
1132
+ # Deterministic artifact facts (review L4): the critic sees the
1133
+ # sanitize/repair honesty flags per written file, not just prose.
1134
+ checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
1135
+ checklist_hint = (
1136
+ f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
1137
+ )
1028
1138
  context = (
1029
1139
  f"{d.critic_prompt}\n\n"
1030
1140
  f"[LANGUAGE HINT: {lang_hint}]\n\n"
1031
1141
  f"Original request: {req.message}\n"
1032
- f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
1142
+ f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}\n\n"
1033
1143
  f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
1034
1144
  )
1035
1145
  raw = await d.generate_as(
@@ -1162,40 +1272,68 @@ class SingleAgentRuntime:
1162
1272
  ctx.state = AgentState.FAILED
1163
1273
 
1164
1274
  # ── ROLLBACK ─────────────────────────────────────────────────────
1275
+ def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
1276
+ for entry in ctx.rollback_log:
1277
+ if entry.get("path") == path:
1278
+ return entry
1279
+ return None
1280
+
1281
+ def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
1282
+ """Recover one path: git when governed and available, else the
1283
+ pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
1284
+ d = self.deps
1285
+ if gov.get("rollback") == "git" and d.rollback_file is not None:
1286
+ try:
1287
+ result = dict(d.rollback_file(str(path)))
1288
+ except Exception as exc: # noqa: BLE001
1289
+ result = {"path": path, "ok": False, "error": str(exc)}
1290
+ if result.get("ok"):
1291
+ result["mode"] = "git"
1292
+ return result
1293
+ snapshot = self._snapshot_for(ctx, str(path))
1294
+ if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
1295
+ content = snapshot.get("content") if snapshot.get("existed") else None
1296
+ try:
1297
+ restored = dict(d.restore_snapshot(str(path), content))
1298
+ except Exception as exc: # noqa: BLE001
1299
+ restored = {"path": path, "ok": False, "error": str(exc)}
1300
+ restored.setdefault("path", path)
1301
+ restored["mode"] = "snapshot"
1302
+ return restored
1303
+ return {
1304
+ "path": path, "ok": False, "mode": "none",
1305
+ "error": "no rollback available (git not applicable, no usable snapshot)",
1306
+ }
1307
+
1165
1308
  def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
1166
- """ROLLBACK: attempt git checkout for each edited file, then FAILED."""
1309
+ """ROLLBACK: recover written files (git snapshot none), then FAILED."""
1167
1310
  d = self.deps
1168
1311
  rolled: List[dict] = []
1312
+ seen_paths: set = set()
1169
1313
  for step in ctx.transcript:
1170
1314
  if step.get("state") != AgentState.EXECUTING.value:
1171
1315
  continue
1172
- gov = step.get("governance", {})
1173
- if gov.get("rollback") != "git":
1316
+ if not isinstance(step.get("result"), dict):
1174
1317
  continue
1175
- result = step.get("result", {})
1176
- if not isinstance(result, dict):
1177
- result = {}
1178
- path = result.get("path") or (step.get("args") or {}).get("path", "")
1179
- if not path:
1318
+ gov = step.get("governance", {}) or {}
1319
+ path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
1320
+ if not path or str(path) in seen_paths:
1180
1321
  continue
1181
- if d.rollback_file is None:
1182
- rolled.append({"path": path, "ok": False, "error": "rollback_file port is not configured"})
1322
+ if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
1183
1323
  continue
1184
- try:
1185
- rolled.append(d.rollback_file(str(path)))
1186
- except Exception as exc:
1187
- rolled.append({"path": path, "ok": False, "error": str(exc)})
1324
+ seen_paths.add(str(path))
1325
+ rolled.append(self._rollback_one(ctx, str(path), gov))
1188
1326
 
1189
1327
  ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
1190
1328
  ctx.trace.decision(
1191
1329
  "rollback", decision="rolled_back",
1192
1330
  attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
1193
1331
  )
1194
- recovered = [r["path"] for r in rolled if r.get("ok")]
1332
+ recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
1195
1333
  ctx.final_message = (
1196
1334
  f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
1197
1335
  if recovered
1198
- else "롤백을 시도했으나 복구할 파일이 없거나 git 초기화되지 않았습니다."
1336
+ else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
1199
1337
  )
1200
1338
  d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
1201
1339
  self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.9.4"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.9.5"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.9.4"
14
+ MARKETPLACE_VERSION = "9.9.5"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -113,6 +113,7 @@ class AgentRunStore:
113
113
  token: str,
114
114
  expires_epoch: float,
115
115
  expires_at: str,
116
+ legacy_context: bool = False,
116
117
  ) -> bool:
117
118
  """Persist a paused run. Best-effort: returns False on any failure."""
118
119
  path = self._path_for(run_id)
@@ -126,6 +127,7 @@ class AgentRunStore:
126
127
  "token_hash": hash_approval_token(token),
127
128
  "expires_epoch": float(expires_epoch),
128
129
  "expires_at": expires_at,
130
+ "legacy_context": bool(legacy_context),
129
131
  "req": req_payload,
130
132
  "ctx": serialize_run_context(ctx),
131
133
  }
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.9.4"
52
+ WORKSPACE_OS_VERSION = "9.9.5"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type