memleaf 0.2.80__tar.gz → 0.2.81__tar.gz

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 (176) hide show
  1. {memleaf-0.2.80 → memleaf-0.2.81}/CHANGELOG.md +18 -0
  2. {memleaf-0.2.80/src/memleaf.egg-info → memleaf-0.2.81}/PKG-INFO +5 -2
  3. {memleaf-0.2.80 → memleaf-0.2.81}/README.en.md +3 -1
  4. {memleaf-0.2.80 → memleaf-0.2.81}/README.md +4 -1
  5. memleaf-0.2.81/docs/failed-run-recovery.md +56 -0
  6. {memleaf-0.2.80 → memleaf-0.2.81}/pyproject.toml +1 -1
  7. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/__init__.py +1 -1
  8. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/cli.py +22 -2
  9. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  10. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_execution.py +7 -2
  11. memleaf-0.2.81/src/memleaf/incremental_failed_recovery.py +184 -0
  12. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_run_state.py +18 -1
  13. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/base.py +7 -1
  14. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/mcp_server.py +21 -0
  15. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/processing_route.py +3 -1
  16. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/service.py +8 -0
  17. {memleaf-0.2.80 → memleaf-0.2.81/src/memleaf.egg-info}/PKG-INFO +5 -2
  18. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf.egg-info/SOURCES.txt +2 -0
  19. {memleaf-0.2.80 → memleaf-0.2.81}/LICENSE +0 -0
  20. {memleaf-0.2.80 → memleaf-0.2.81}/MANIFEST.in +0 -0
  21. {memleaf-0.2.80 → memleaf-0.2.81}/docs/acceptance-evidence-guide.md +0 -0
  22. {memleaf-0.2.80 → memleaf-0.2.81}/docs/automatic-processing-route.md +0 -0
  23. {memleaf-0.2.80 → memleaf-0.2.81}/docs/body-compaction.md +0 -0
  24. {memleaf-0.2.80 → memleaf-0.2.81}/docs/candidate-reconciliation.md +0 -0
  25. {memleaf-0.2.80 → memleaf-0.2.81}/docs/capture-budget-design.md +0 -0
  26. {memleaf-0.2.80 → memleaf-0.2.81}/docs/config-migrations.md +0 -0
  27. {memleaf-0.2.80 → memleaf-0.2.81}/docs/core-refactor.md +0 -0
  28. {memleaf-0.2.80 → memleaf-0.2.81}/docs/development-closeout.md +0 -0
  29. {memleaf-0.2.80 → memleaf-0.2.81}/docs/evidence-retention.md +0 -0
  30. {memleaf-0.2.80 → memleaf-0.2.81}/docs/explicit-memory-update.md +0 -0
  31. {memleaf-0.2.80 → memleaf-0.2.81}/docs/extraction-latency.md +0 -0
  32. {memleaf-0.2.80 → memleaf-0.2.81}/docs/gate-evidence-boundary.md +0 -0
  33. {memleaf-0.2.80 → memleaf-0.2.81}/docs/general-processing.md +0 -0
  34. {memleaf-0.2.80 → memleaf-0.2.81}/docs/hermes-mcp-runtime.md +0 -0
  35. {memleaf-0.2.80 → memleaf-0.2.81}/docs/hermes-provider-compatibility.md +0 -0
  36. {memleaf-0.2.80 → memleaf-0.2.81}/docs/host-acceptance-runbook.md +0 -0
  37. {memleaf-0.2.80 → memleaf-0.2.81}/docs/immutable-release-assets.md +0 -0
  38. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-commit-contract.md +0 -0
  39. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-execution.md +0 -0
  40. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-native-comparison.md +0 -0
  41. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-partial-recovery.md +0 -0
  42. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-planner-preview.md +0 -0
  43. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-runtime-contract.md +0 -0
  44. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-scope-registration.md +0 -0
  45. {memleaf-0.2.80 → memleaf-0.2.81}/docs/incremental-selected-retention.md +0 -0
  46. {memleaf-0.2.80 → memleaf-0.2.81}/docs/installed-artifact-verification.md +0 -0
  47. {memleaf-0.2.80 → memleaf-0.2.81}/docs/memory-state-contract.md +0 -0
  48. {memleaf-0.2.80 → memleaf-0.2.81}/docs/migration-preflight.md +0 -0
  49. {memleaf-0.2.80 → memleaf-0.2.81}/docs/processing-quality-acceptance.md +0 -0
  50. {memleaf-0.2.80 → memleaf-0.2.81}/docs/public-query-integrity.md +0 -0
  51. {memleaf-0.2.80 → memleaf-0.2.81}/docs/query-performance.md +0 -0
  52. {memleaf-0.2.80 → memleaf-0.2.81}/docs/remember-incremental-route.md +0 -0
  53. {memleaf-0.2.80 → memleaf-0.2.81}/docs/runtime-state-retention.md +0 -0
  54. {memleaf-0.2.80 → memleaf-0.2.81}/docs/semantic-extraction-protocol.md +0 -0
  55. {memleaf-0.2.80 → memleaf-0.2.81}/docs/source-work-contract.md +0 -0
  56. {memleaf-0.2.80 → memleaf-0.2.81}/docs/v0.2.26-processing-status.md +0 -0
  57. {memleaf-0.2.80 → memleaf-0.2.81}/examples/README.md +0 -0
  58. {memleaf-0.2.80 → memleaf-0.2.81}/examples/basic_usage.py +0 -0
  59. {memleaf-0.2.80 → memleaf-0.2.81}/examples/incremental_acceptance.json +0 -0
  60. {memleaf-0.2.80 → memleaf-0.2.81}/examples/mcp_stdio.ndjson +0 -0
  61. {memleaf-0.2.80 → memleaf-0.2.81}/examples/query_benchmark.py +0 -0
  62. {memleaf-0.2.80 → memleaf-0.2.81}/install.ps1 +0 -0
  63. {memleaf-0.2.80 → memleaf-0.2.81}/install.sh +0 -0
  64. {memleaf-0.2.80 → memleaf-0.2.81}/setup.cfg +0 -0
  65. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/__main__.py +0 -0
  66. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/acceptance.py +0 -0
  67. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/adapters/__init__.py +0 -0
  68. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/adapters/antigravity.py +0 -0
  69. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/adapters/base.py +0 -0
  70. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/adapters/codex.py +0 -0
  71. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/adapters/hermes.py +0 -0
  72. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/admission.py +0 -0
  73. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/batch_review.py +0 -0
  74. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/budget.py +0 -0
  75. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/capture.py +0 -0
  76. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/compaction.py +0 -0
  77. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/config.py +0 -0
  78. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/create_coordinator.py +0 -0
  79. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/credentials.py +0 -0
  80. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/evidence_budget.py +0 -0
  81. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/evidence_policy.py +0 -0
  82. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/evidence_structure.py +0 -0
  83. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/evidence_syntax.py +0 -0
  84. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/explicit_text_source.py +0 -0
  85. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/extraction_budget.py +0 -0
  86. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/extraction_capability.py +0 -0
  87. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/extraction_work_state.py +0 -0
  88. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/frontmatter.py +0 -0
  89. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/README.md +0 -0
  90. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/__init__.py +0 -0
  91. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/_mcp_client.py +0 -0
  92. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/_provider.py +0 -0
  93. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/_shared.py +0 -0
  94. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/evidence_budget.py +0 -0
  95. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_provider/provider_compatibility.py +0 -0
  96. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/hermes_runtime.py +0 -0
  97. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/host_events.py +0 -0
  98. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/host_runtime.py +0 -0
  99. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/inbox.py +0 -0
  100. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_commit.py +0 -0
  101. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_dates.py +0 -0
  102. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_explicit.py +0 -0
  103. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_journal.py +0 -0
  104. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_native.py +0 -0
  105. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_partial.py +0 -0
  106. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_preview.py +0 -0
  107. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_prompts.py +0 -0
  108. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_protocol.py +0 -0
  109. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_recovery.py +0 -0
  110. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_runtime.py +0 -0
  111. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_scopes.py +0 -0
  112. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/incremental_selection.py +0 -0
  113. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/index.py +0 -0
  114. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/inspection.py +0 -0
  115. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/installer.py +0 -0
  116. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/legacy_update_journal.py +0 -0
  117. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/__init__.py +0 -0
  118. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/claude_compatible.py +0 -0
  119. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/gemini.py +0 -0
  120. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/openai_compatible.py +0 -0
  121. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/router.py +0 -0
  122. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/llm/thinking.py +0 -0
  123. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/locking.py +0 -0
  124. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/memory_commit.py +0 -0
  125. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/memory_planner.py +0 -0
  126. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/memory_retraction.py +0 -0
  127. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/memory_update.py +0 -0
  128. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/memory_writer.py +0 -0
  129. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/migration.py +0 -0
  130. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/model_capabilities.py +0 -0
  131. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/model_discovery.py +0 -0
  132. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/model_execution.py +0 -0
  133. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/models.py +0 -0
  134. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/native_index.py +0 -0
  135. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/native_registration.py +0 -0
  136. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/parallel_model.py +0 -0
  137. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/planning_context.py +0 -0
  138. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/process_common.py +0 -0
  139. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/process_jobs.py +0 -0
  140. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/process_journal.py +0 -0
  141. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/process_owner.py +0 -0
  142. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/processing.py +0 -0
  143. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/prompts.py +0 -0
  144. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/provenance.py +0 -0
  145. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/provider_compatibility.py +0 -0
  146. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/query_clock.py +0 -0
  147. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/query_progress.py +0 -0
  148. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/query_scan.py +0 -0
  149. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/receipt_codec.py +0 -0
  150. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/recording_policy.py +0 -0
  151. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/redaction.py +0 -0
  152. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/remember_route.py +0 -0
  153. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/retention.py +0 -0
  154. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/retrieval.py +0 -0
  155. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/retrieval_gate.py +0 -0
  156. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/runtime_retention.py +0 -0
  157. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/scope_maintenance.py +0 -0
  158. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/scope_state.py +0 -0
  159. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/semantic_maintenance.py +0 -0
  160. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/semantic_protocol.py +0 -0
  161. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/single_pass_memory_planner.py +0 -0
  162. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/single_pass_plan.py +0 -0
  163. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/source_policy.py +0 -0
  164. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/state_layout.py +0 -0
  165. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/subprocess_flags.py +0 -0
  166. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/summary_batch.py +0 -0
  167. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/target_reconciliation.py +0 -0
  168. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/turn_audit.py +0 -0
  169. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/turn_plan.py +0 -0
  170. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/update_coordinator.py +0 -0
  171. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/update_review.py +0 -0
  172. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/validation.py +0 -0
  173. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf/vault.py +0 -0
  174. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf.egg-info/dependency_links.txt +0 -0
  175. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf.egg-info/entry_points.txt +0 -0
  176. {memleaf-0.2.80 → memleaf-0.2.81}/src/memleaf.egg-info/top_level.txt +0 -0
@@ -4,6 +4,24 @@ All notable changes to memleaf are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.81 — 2026-09-30
8
+
9
+ ### HTTP failure classification and controlled run recovery
10
+
11
+ - Preserve safe numeric HTTP status in model errors, request attempts and
12
+ processing diagnostics. Allow 408/500/502/503/504 to use the remaining original
13
+ transport allowance; known permanent errors stay closed.
14
+ - Add revision-bound failed automatic run preview/apply through the Python API,
15
+ `recover-run` CLI and `recover_failed_run` MCP tool. Historical unclassified
16
+ HTTP errors require explicit acknowledgement; ordinary recovery stays unchanged.
17
+ - Rebuild current comparison context for unchanged sources while retaining run,
18
+ commit and budget identities, prior attempts and recovery audit. Reject consumed,
19
+ changed or revoked sources, conflicting ownership, exhausted budgets and full
20
+ active receipt capacity. Repeated execution and interrupted writes remain bounded.
21
+ - Validation: 121 focused checks passed and 10 historical failed runs exercised
22
+ isolated recovery with an injected HTTP503, without network/model calls or
23
+ formal Vault changes. Real-model semantic quality remains unverified.
24
+
7
25
  ## 0.2.80 — 2026-09-29
8
26
 
9
27
  ### Explicit deadline annotations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.80
3
+ Version: 0.2.81
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,7 +23,7 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **版本:0.2.80。**
26
+ > **版本:0.2.81。**
27
27
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
28
28
  > 提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),首轮、主题选择、候选修复和 `semantic_maintenance` 使用同一套保留价值判断;同一件事只写一次。
29
29
  > Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
@@ -280,6 +280,7 @@ python -m memleaf.mcp_server --vault "$HOME/.memleaf"
280
280
  | `read` | 使用当前 `retrieval_id` 分页读取选中的记忆正文 |
281
281
  | `process` | 处理完整 inbox 轮次并按准入规则提炼;`background=true` 时异步入队 |
282
282
  | `process_status` | 只读查询异步处理 job 状态 |
283
+ | `recover_failed_run` | 预览并显式恢复终态传输失败,保留原请求预算 |
283
284
  | `remember` | 用户明确要求时创建或更新记忆 |
284
285
  | `forget_memory` | 按精确 ID 删除一条记忆 |
285
286
  | `forget_about` | 忘记明确主题;有歧义时只返回候选 |
@@ -529,3 +530,5 @@ memleaf process --vault /path/to/existing/vault --source hermes --session-id SES
529
530
  边界在宿主采集、Core capture 和历史 inbox 的新处理处执行。
530
531
  已提交记忆不会自动删除。旧版本已冻结但未完成的计划不会被静默丢弃或重新规划,
531
532
  不含新来源边界标记的计划会停止恢复并保留现场,避免越过新的输入约定。
533
+
534
+ 历史 HTTP 失败的受控恢复及 CLI 用法见 [失败任务恢复](docs/failed-run-recovery.md)。
@@ -4,7 +4,7 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.80.**
7
+ > **Version: 0.2.81.**
8
8
  > Automatic extraction and review share one future-value standard: the model weighs likely reuse, information gain, direct usability when read again, and the cost of forgetting. It keeps only the smallest core that can materially help future understanding, decisions, or actions; information without clear value is not extracted, and no business-specific exclusion rule is hard-coded.
9
9
  > A single Chinese semantic contract is sent for extraction, topic selection, candidate repair, and `semantic_maintenance`; the older English B3 prompt has been deleted and B3 remains only as a compatibility read format. One topic is written once.
10
10
  > Core no longer discards a whole candidate over ownership, dates, task basis or cross-project wording. A missing owner normalizes to the single project named by that candidate's own evidence, otherwise `global`. A deadline is accepted only when it appears in the candidate's own evidence and can be anchored to ISO (`明天`, `周五`, `月底` all resolve); when it cannot, only the date field is dropped and the memory is still written. An unauthorized ownership change keeps the target's existing scope.
@@ -516,3 +516,5 @@ The boundary is enforced by host capture, Core capture and new processing of his
516
516
  inbox records. Committed memories are not deleted automatically. Frozen operations without
517
517
  the conversation-only policy marker are not silently replayed or replanned; they remain
518
518
  available for inspection so the new input contract is not bypassed.
519
+
520
+ Controlled historical HTTP recovery and CLI usage: [failed-run recovery](docs/failed-run-recovery.md).
@@ -4,7 +4,7 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **版本:0.2.80。**
7
+ > **版本:0.2.81。**
8
8
  > 自动提炼和复核共用同一条“未来记忆价值”标准:模型综合未来复用、信息增量、再次读取时的直接可用性和忘记成本,只保留对未来理解、判断或行动有实质影响的最小核心;没有明确价值的信息不提炼,不按具体业务场景硬编码排除。
9
9
  > 提炼契约现在只有一份中文语义契约(旧版英文 B3 提示词已删除),首轮、主题选择、候选修复和 `semantic_maintenance` 使用同一套保留价值判断;同一件事只写一次。
10
10
  > Core 不再因为归属、日期、任务依据或跨项目而丢弃整条候选:归属缺失会归一到证据里唯一被点名的项目、否则落 `global`;日期只接受能在本候选证据里找到出处、且能锚定成 ISO 的写法(`明天`、`周五`、`月底` 都会被解析),无法解析时只丢掉日期字段,记忆照常写入;未获授权的改归属沿用目标原归属。
@@ -261,6 +261,7 @@ python -m memleaf.mcp_server --vault "$HOME/.memleaf"
261
261
  | `read` | 使用当前 `retrieval_id` 分页读取选中的记忆正文 |
262
262
  | `process` | 处理完整 inbox 轮次并按准入规则提炼;`background=true` 时异步入队 |
263
263
  | `process_status` | 只读查询异步处理 job 状态 |
264
+ | `recover_failed_run` | 预览并显式恢复终态传输失败,保留原请求预算 |
264
265
  | `remember` | 用户明确要求时创建或更新记忆 |
265
266
  | `forget_memory` | 按精确 ID 删除一条记忆 |
266
267
  | `forget_about` | 忘记明确主题;有歧义时只返回候选 |
@@ -510,3 +511,5 @@ memleaf process --vault /path/to/existing/vault --source hermes --session-id SES
510
511
  边界在宿主采集、Core capture 和历史 inbox 的新处理处执行。
511
512
  已提交记忆不会自动删除。旧版本已冻结但未完成的计划不会被静默丢弃或重新规划,
512
513
  不含新来源边界标记的计划会停止恢复并保留现场,避免越过新的输入约定。
514
+
515
+ 历史 HTTP 失败的受控恢复及 CLI 用法见 [失败任务恢复](docs/failed-run-recovery.md)。
@@ -0,0 +1,56 @@
1
+ # HTTP 失败与历史任务恢复
2
+
3
+ HTTP 传输层保留经过校验的数字 `http_status`,不保存响应正文、URL、请求头或密钥。
4
+ 408、500、502、503、504 可在原预算仍有余额时进入 `retryable`;429 使用现有
5
+ 限流处理。400、401、403、404、501 等不因新的 HTTP 分类而获得重试。
6
+ 缺少状态码的历史 `model_http_error` 保持终态,普通 `process --recover` 不会重开它。
7
+
8
+ ## 按 run_id 预览及执行
9
+
10
+ ```sh
11
+ # 预览:只读,无模型调用。旧 HTTP 回执必须明确接受其状态码未知。
12
+ memleaf recover-run --vault /path/to/vault --run-id inc-run-... \
13
+ --allow-legacy-http --json
14
+
15
+ # 仅当 recoverable=true 时,使用此次预览返回的完整 expected_revision。
16
+ memleaf recover-run --vault /path/to/vault --run-id inc-run-... \
17
+ --allow-legacy-http --apply --expected-revision PREVIEW_REVISION --json
18
+ ```
19
+
20
+ `--allow-legacy-http` 不放行已知的永久 HTTP 错误,也不增加预算。执行使用当前
21
+ Vault 配置的固定 API 模型路由;预览不解析模型路由、不初始化 Vault、不创建锁文件。
22
+ MCP 对应工具为 `recover_failed_run`,参数为 `run_id`、`allow_legacy_http`、
23
+ `apply`(默认 false)及 `expected_revision`。
24
+
25
+ Python API:
26
+
27
+ ```python
28
+ preview = service.recover_failed_run(run_id, allow_legacy_http=True)
29
+ if preview["recoverable"]:
30
+ result = service.recover_failed_run(
31
+ run_id, dry_run=False, allow_legacy_http=True,
32
+ expected_revision=preview["expected_revision"],
33
+ )
34
+ ```
35
+
36
+ ## 恢复边界
37
+
38
+ - 只处理自动捕获任务的已知第一次传输失败,原请求预算为已用 1、尚有余额。
39
+ 显式 remember 的授权正文已清除时,不能借此重新取得保留授权。
40
+ - 原轮必须完整且可处理,来源摘要及原预算身份必须一致。来源被删除、修改、禁录,
41
+ 已有消费记录、自动提交、旧流程待提交计划或其他活动所有者时拒绝恢复。
42
+ - 原 run_id、budget_id、turn_budget_id、commit identity 和第一次尝试记录全部保留。
43
+ 执行在锁内绑定此次预览版本,并记录旧失败、旧来源窗口、旧快照及恢复时间。
44
+ 不修改原额度、不新建 run、不删除旧回执。
45
+ - 原请求正文已清除,因此执行根据当前记忆重新生成快照和请求。完整后续对话仅以
46
+ `use=context` 参与比较,不独立创建新事项,也不随本轮结算。已有日期、来源及
47
+ `stale_observation` 校验继续生效;不能证明安全的修改留下未解决项。
48
+ - 预览后状态、预算、来源或比较目标变化,需要重新预览。绑定后的调用前/提交前
49
+ 校验不允许再次悄悄更新快照。
50
+ - 第二次请求仍先预留再派发。崩溃后的未知调用保守计入预算;重复 apply 使用同一
51
+ 恢复绑定,完成后只返回旧回执。保存的回复/提交可在没有模型配置时恢复。
52
+ - compact 回执重开需要一个可用 full 槽位;容量不足时预览明确拒绝,不能删除
53
+ 回执来换取执行机会。
54
+
55
+ 真实模型可能输出 NO_MEMORY、NO_CHANGE、UPDATE、CREATE 或未解决项;入口可执行
56
+ 不等于模型语义正确,也不承诺所有历史内容都应保存。
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.80"
7
+ version = "0.2.81"
8
8
  description = "A local-first Markdown memory core for AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """Local-first Markdown memory core for AI agents."""
2
2
 
3
- __version__ = "0.2.80"
3
+ __version__ = "0.2.81"
4
4
 
5
5
  from .config import DEFAULT_CONFIG, default_config, load_config, save_config
6
6
  from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
@@ -100,6 +100,13 @@ def build_parser() -> argparse.ArgumentParser:
100
100
  help="allow remaining incremental transport recovery, not partial replan")
101
101
  process.add_argument("--dry-run", action="store_true", help="no source Vault writes; may call the configured model")
102
102
  process.add_argument("--json", action="store_true")
103
+ recovery = commands.add_parser("recover-run", help="preview or explicitly replan a failed automatic transport run")
104
+ recovery.add_argument("--vault", type=Path, default=None)
105
+ recovery.add_argument("--run-id", required=True)
106
+ recovery.add_argument("--apply", action="store_true", help="use the original remaining budget; requires preview revision")
107
+ recovery.add_argument("--expected-revision", default=None)
108
+ recovery.add_argument("--allow-legacy-http", action="store_true", help="explicitly retry one old HTTP failure whose status was not recorded")
109
+ recovery.add_argument("--json", action="store_true")
103
110
  maintenance = commands.add_parser("maintain-state", help="preview lossless terminal control-state compaction")
104
111
  maintenance.add_argument("--vault", type=Path, default=None)
105
112
  maintenance.add_argument("--apply", action="store_true", help="apply only to --expected-revision from preview")
@@ -274,6 +281,17 @@ def main(argv: Sequence[str] | None = None) -> int:
274
281
  from .service import Memleaf
275
282
  output = Memleaf(existing_root(args.vault)).process(
276
283
  source=args.source, session_id=args.session_id, scope=args.scope, pipeline=args.pipeline, recover=args.recover)
284
+ elif args.command == "recover-run":
285
+ from .inspection import existing_root
286
+ from .service import Memleaf
287
+ from .incremental_failed_recovery import FailedRecoveryError
288
+ service = Memleaf(Vault(existing_root(args.vault), create=False))
289
+ try:
290
+ output = service.recover_failed_run(args.run_id, dry_run=not args.apply,
291
+ expected_revision=args.expected_revision, allow_legacy_http=args.allow_legacy_http)
292
+ except FailedRecoveryError as error:
293
+ print(json.dumps({"run_id": args.run_id, "code": error.code, "status": "rejected"}))
294
+ return 1
277
295
  elif args.command == "maintain-state":
278
296
  from .inspection import existing_root
279
297
  from .service import Memleaf
@@ -318,15 +336,17 @@ def main(argv: Sequence[str] | None = None) -> int:
318
336
  print("{}")
319
337
  return 0
320
338
  if getattr(args, "json", False):
321
- print(json.dumps({"error": "operation failed" if args.command in {"audit", "process", "maintain-state", "migration-check", "migration-backup"}
339
+ print(json.dumps({"error": "operation failed" if args.command in {"audit", "process", "recover-run", "maintain-state", "migration-check", "migration-backup"}
322
340
  else "initialization failed", "stage": args.command}, ensure_ascii=False))
323
341
  else:
324
342
  action = getattr(args, "command", "init")
325
343
  print(f"memleaf {action} failed unexpectedly", file=sys.stderr)
326
344
  return 1
327
345
 
328
- if args.command in {"audit", "process", "maintain-state", "migration-check", "migration-backup"}:
346
+ if args.command in {"audit", "process", "recover-run", "maintain-state", "migration-check", "migration-backup"}:
329
347
  print(json.dumps(output, ensure_ascii=False, sort_keys=True, indent=None if args.json else 2))
348
+ if args.command == "recover-run":
349
+ return 2 if output.get("recoverable") is False or output.get("execution_status") in {"failed", "blocked", "cancelled"} else 0
330
350
  return 2 if args.command == "migration-check" and output["local_status"] != "clear" else 0
331
351
  if args.command == "host-event":
332
352
  print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.80
2
+ version: 0.2.81
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -28,7 +28,7 @@ from .incremental_run_state import (
28
28
  )
29
29
  from .index import turn_key
30
30
  from .inbox import captured_turn_selector
31
- from .llm.base import ModelError
31
+ from .llm.base import ModelError, HTTP_RETRYABLE_STATUSES
32
32
  from .locking import atomic_write_json
33
33
  from .models import utc_now
34
34
  from .process_common import _read_processed
@@ -295,12 +295,14 @@ def _drive(service, run_id, token, backend, calls):
295
295
  if ordinal > 1 and not run.get("partial_used"):
296
296
  request["system"] += RETRY_SYSTEM
297
297
  error_code = None
298
+ http_status = None
298
299
  response = None
299
300
  try:
300
301
  calls[0] += 1
301
302
  response = backend.complete(request["user"], system=request["system"], purpose="single_pass")
302
303
  except ModelError as error:
303
304
  error_code = error.code
305
+ http_status = error.http_status
304
306
  except Exception:
305
307
  error_code = "model_failed" # Never persist provider exception text.
306
308
  with service.vault.lock():
@@ -309,7 +311,10 @@ def _drive(service, run_id, token, backend, calls):
309
311
  return public_result(run, calls=calls[0])
310
312
  if error_code is not None:
311
313
  run["attempts"][-1]["outcome"] = error_code
312
- retryable = error_code in TRANSIENT and run["reserved_requests"] < 2
314
+ if http_status is not None:
315
+ run["attempts"][-1]["http_status"] = http_status
316
+ retryable = (error_code in TRANSIENT or
317
+ (error_code == "model_http_error" and http_status in HTTP_RETRYABLE_STATUSES)) and run["reserved_requests"] < 2
313
318
  _finish(service, processed, run, "retryable" if retryable else "failed", error_code)
314
319
  return public_result(run, calls=calls[0])
315
320
  if not isinstance(response, str) or not response.strip() or len(response.encode("utf-8")) > MAX_BYTES:
@@ -0,0 +1,184 @@
1
+ """Explicit, revision-bound recovery of failed automatic transport runs.
2
+
3
+ Preview never opens a Vault lock, calls a model or reconstructs persisted
4
+ requests. Apply rebuilds a current request for unchanged source under the same
5
+ run and budget, retaining the original failed attempt and a bounded audit.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+ from .extraction_work_state import _read_budget_state_unlocked, ExtractionWorkStateError
14
+ from .incremental_commit import _window
15
+ from .incremental_execution import (TRANSIENT, RETRY_SYSTEM, _guard_legacy, _protocol_digest,
16
+ resume_incremental_run)
17
+ from .incremental_journal import digest, load_work
18
+ from .incremental_preview import _prepare_incremental_unlocked
19
+ from .incremental_prompts import INCREMENTAL_SYSTEM
20
+ from .incremental_protocol import MAX_BYTES
21
+ from .incremental_run_state import TERMINAL, MAX_RUNS, COMPACT_VERSION, load_run, owner_live, public_result, save_run
22
+ from .llm.base import HTTP_RETRYABLE_STATUSES
23
+ from .models import utc_now
24
+ from .process_common import _read_processed
25
+ from .recording_policy import recording_allowed
26
+ from .turn_plan import input_digest, turn_identity_key
27
+ from .receipt_codec import is_compact, ledger_usage
28
+
29
+
30
+ class FailedRecoveryError(ValueError):
31
+ """Bounded public rejection, without private provider/source text."""
32
+
33
+ def __init__(self, code: str):
34
+ super().__init__(code)
35
+ self.code = code
36
+
37
+
38
+ def _reject(code: str):
39
+ raise FailedRecoveryError(code)
40
+
41
+
42
+ def _plan(service: Any, run_id: str, *, allow_legacy_http: bool):
43
+ processed = _read_processed(service.vault.processed_state_path)
44
+ run = load_run(processed, run_id)
45
+ if run is None:
46
+ _reject('incremental_run_not_found')
47
+ if (is_compact(processed['incremental_runs'][run_id], COMPACT_VERSION)
48
+ and ledger_usage(processed['incremental_runs'], compact_version=COMPACT_VERSION)['full'] >= MAX_RUNS):
49
+ _reject('active_run_capacity_unavailable')
50
+ if turn_identity_key(run['source'], run['session_id'], run['turn_key']) in processed.get('pending_turn_plans', {}):
51
+ _reject('legacy_pending_plan')
52
+ if run['status'] != 'failed' or 'terminal_recovery' in run:
53
+ _reject('terminal_recovery_not_available')
54
+ if run.get('request_kind', 'automatic') != 'automatic' or run['arguments'].get('selection'):
55
+ _reject('explicit_retention_requires_new_authorization')
56
+ if owner_live(processed):
57
+ _reject('incremental_model_busy')
58
+ _guard_legacy(service, processed)
59
+ attempts = run['attempts']
60
+ # Only a known failed first transport dispatch. Unknown reservations,
61
+ # responses, partial commits and exhausted attempts cannot be reopened.
62
+ if (len(attempts) != 1 or attempts[0]['ordinal'] != 1
63
+ or attempts[0]['outcome'] != run.get('code')
64
+ or run['reserved_requests'] != 1 or run.get('partial_used')):
65
+ _reject('terminal_recovery_attempt_not_eligible')
66
+ code = run.get('code')
67
+ status = attempts[0].get('http_status')
68
+ legacy = code == 'model_http_error' and status is None
69
+ if code == 'model_http_error':
70
+ if legacy and not allow_legacy_http:
71
+ _reject('legacy_http_status_unknown')
72
+ if not legacy and status not in HTTP_RETRYABLE_STATUSES:
73
+ _reject('http_error_not_retryable')
74
+ elif code not in TRANSIENT:
75
+ _reject('failure_not_retryable')
76
+ if not recording_allowed(processed, run['source'], run['session_id'], run['turn_key']):
77
+ _reject('source_recording_revoked')
78
+ if run['commit_work_id'] in processed.get('incremental_commits', {}):
79
+ _reject('source_already_owned')
80
+ turn, window = _window(service, run['source'], run['session_id'], run['turn_key'])
81
+ if not turn.processable or input_digest(turn) != run['source_digest']:
82
+ _reject('source_changed')
83
+ from .extraction_work_state import extraction_work_id
84
+ if extraction_work_id(turn, request_kind='automatic', intent_id='automatic') != run['budget_id']:
85
+ _reject('source_budget_identity_changed')
86
+ session = processed.get('sessions', {}).get(f"{run['source']}/{run['session_id']}", {})
87
+ if any(isinstance(entry, dict) and entry.get('turn_key') == run['turn_key']
88
+ for entry in session.get('processed_turns', [])):
89
+ _reject('source_already_processed')
90
+ for key in processed.get('incremental_commits', {}):
91
+ work = load_work(processed, key)
92
+ if (work['source'] == run['source'] and work['session_id'] == run['session_id']
93
+ and work['turn_key'] == run['turn_key']
94
+ and work.get('request_kind', 'automatic') == 'automatic'):
95
+ _reject('source_already_owned')
96
+ for key in processed.get('incremental_runs', {}):
97
+ other = load_run(processed, key)
98
+ if (key != run_id and other['source'] == run['source']
99
+ and other['session_id'] == run['session_id'] and other['turn_key'] == run['turn_key']
100
+ and other['status'] not in TERMINAL):
101
+ _reject('source_already_owned')
102
+ try:
103
+ budget = _read_budget_state_unlocked(service.vault)
104
+ except ExtractionWorkStateError:
105
+ _reject('remaining_request_budget_unavailable')
106
+ work_budget = budget['works'].get(run['budget_id'], {})
107
+ row = work_budget.get('turns', {}).get(run['turn_budget_id'])
108
+ if (not row or work_budget.get('retired') or row['completed'] or run.get('budget_finalized')
109
+ or row['requests'] != 1 or row['request_limit_at_creation'] <= row['requests']):
110
+ _reject('remaining_request_budget_unavailable')
111
+ snapshot = _prepare_incremental_unlocked(service, **run['arguments'])
112
+ request = {'system': INCREMENTAL_SYSTEM,
113
+ 'user': json.dumps(snapshot.model_input(), ensure_ascii=False, separators=(',', ':'))}
114
+ if sum(len(s.encode('utf-8')) for s in request.values()) + len(RETRY_SYSTEM.encode('utf-8')) > MAX_BYTES:
115
+ _reject('blocked_context')
116
+ # Preview has no advisory lock. Confirm the independently read planning
117
+ # state/budget stayed stable; apply repeats this inside the existing lock.
118
+ if (processed != _read_processed(service.vault.processed_state_path)
119
+ or budget != _read_budget_state_unlocked(service.vault)):
120
+ _reject('recovery_preview_changed')
121
+ revision = digest({'processed': processed, 'budget': budget, 'source_window': window,
122
+ 'snapshot': snapshot.snapshot_id, 'protocol': _protocol_digest(),
123
+ 'allow_legacy_http': allow_legacy_http, 'run_id': run_id})
124
+ result = {'run_id': run_id, 'recoverable': True, 'read_only': True, 'model_calls': 0,
125
+ 'expected_revision': revision, 'remaining_requests': 1,
126
+ 'http_status': status, 'legacy_http_status_unknown': legacy,
127
+ 'source_unchanged': True, 'source_window_changed': window != run['source_window'],
128
+ 'snapshot_changed': snapshot.snapshot_id != run['snapshot_id'],
129
+ 'new_events': sum(e['use'] == 'new' for e in snapshot.state()['evidence']),
130
+ 'context_events': sum(e['use'] == 'context' for e in snapshot.state()['evidence'])}
131
+ return processed, run, snapshot, window, request, result
132
+
133
+
134
+ def recover_failed_run(service: Any, run_id: str, *, dry_run: bool = True,
135
+ expected_revision: str | None = None, allow_legacy_http: bool = False,
136
+ model: Any = None, router: Any = None) -> dict[str, Any]:
137
+ if type(dry_run) is not bool or type(allow_legacy_http) is not bool:
138
+ _reject('invalid_recovery_options')
139
+ if dry_run:
140
+ try:
141
+ return _plan(service, run_id, allow_legacy_http=allow_legacy_http)[-1]
142
+ except (ValueError, OSError, ExtractionWorkStateError) as error:
143
+ # Known local validators emit reason codes, never source bodies.
144
+ code = error.code if isinstance(error, FailedRecoveryError) else 'recovery_preflight_failed'
145
+ return {'run_id': run_id, 'recoverable': False, 'read_only': True,
146
+ 'model_calls': 0, 'code': code}
147
+ if not isinstance(expected_revision, str) or re.fullmatch(r'[0-9a-f]{64}', expected_revision) is None:
148
+ _reject('expected_recovery_revision_required')
149
+ from .incremental_runtime import _resolve
150
+ # A repeated apply is the same original run, not another authorization.
151
+ with service.vault.lock():
152
+ stored = load_run(_read_processed(service.vault.processed_state_path), run_id)
153
+ audit = stored.get('terminal_recovery') if stored else None
154
+ if audit is not None:
155
+ if audit['expected_revision'] != expected_revision or audit['allow_legacy_http'] != allow_legacy_http:
156
+ _reject('recovery_already_bound')
157
+ if stored['status'] in TERMINAL:
158
+ return public_result(stored)
159
+ # Active/resumable same-intent work uses the existing owner guard.
160
+ already_bound = True
161
+ else:
162
+ already_bound = False
163
+ if not already_bound:
164
+ processed, run, snapshot, window, request, preview = _plan(
165
+ service, run_id, allow_legacy_http=allow_legacy_http)
166
+ if preview['expected_revision'] != expected_revision:
167
+ _reject('stale_recovery_preview')
168
+ backend = _resolve(service, model, router)
169
+ audit = {'version': 1, 'expected_revision': expected_revision,
170
+ 'allow_legacy_http': allow_legacy_http, 'previous_code': run['code'],
171
+ 'previous_snapshot_id': run['snapshot_id'], 'previous_source_window': run['source_window'],
172
+ 'requested_at': utc_now()}
173
+ run.update(status='ready', code=None, snapshot_id=snapshot.snapshot_id, source_window=window,
174
+ request=request, request_digest=digest(request), protocol_digest=_protocol_digest(),
175
+ source_keys=[e['event_key'] for e in snapshot.state()['evidence']],
176
+ target_ids=[t['memory']['memory_id'] for t in snapshot.state()['targets'].values()],
177
+ terminal_recovery=audit)
178
+ save_run(service, processed, run)
179
+ if already_bound:
180
+ # Resolve only if a dispatch is still needed. Saved responses/commits
181
+ # remain recoverable with zero model calls and no API configuration.
182
+ backend = (_resolve(service, model, router) if stored['status'] in {'ready', 'retryable', 'dispatching'}
183
+ else None)
184
+ return resume_incremental_run(service, run_id, backend=backend)
@@ -84,6 +84,20 @@ def load_run(processed: dict[str, Any], run_id: str) -> dict[str, Any] | None:
84
84
  or not 1 <= attempt["ordinal"] <= 2 or attempt.get("outcome") not in
85
85
  ("unknown", "response", "invalid_response", "model_timeout", "model_rate_limited", "model_network_error", "model_failed", "model_auth_failed", "model_unavailable", "model_http_error", "model_invalid_response")):
86
86
  raise ValueError("invalid_incremental_attempt")
87
+ if "http_status" in attempt and (type(attempt["http_status"]) is not int or not 100 <= attempt["http_status"] <= 599):
88
+ raise ValueError("invalid_incremental_http_status")
89
+ audit = run.get("terminal_recovery")
90
+ if audit is not None:
91
+ required = {"version", "expected_revision", "allow_legacy_http", "previous_code",
92
+ "previous_snapshot_id", "previous_source_window", "requested_at"}
93
+ if (not isinstance(audit, dict) or set(audit) != required or type(audit.get("version")) is not int
94
+ or audit["version"] != 1 or type(audit.get("allow_legacy_http")) is not bool
95
+ or audit.get("previous_code") not in {"model_http_error", "model_timeout", "model_rate_limited", "model_network_error", "model_invalid_response"}
96
+ or any(not isinstance(audit.get(k), str) or re.fullmatch(r"[0-9a-f]{64}", audit[k]) is None
97
+ for k in ("expected_revision", "previous_snapshot_id", "previous_source_window"))
98
+ or not isinstance(audit.get("requested_at"), str) or not audit["requested_at"]
99
+ or kind != "automatic" or not run["attempts"] or run["attempts"][0]["outcome"] != audit["previous_code"]):
100
+ raise ValueError("invalid_terminal_recovery")
87
101
  if run["status"] not in TERMINAL:
88
102
  request = run.get("request")
89
103
  if (not isinstance(request, dict) or set(request) != {"system", "user"}
@@ -115,7 +129,7 @@ def save_run(service: Any, processed: dict[str, Any], run: dict[str, Any]) -> No
115
129
  raise ValueError("incremental_runs_full")
116
130
  runs[run["run_id"]] = {"version": VERSION, "payload": payload,
117
131
  "checksum": hashlib.sha256(payload.encode("utf-8")).hexdigest()}
118
- if is_compact(previous, COMPACT_VERSION):
132
+ if is_compact(previous, COMPACT_VERSION) and run["status"] in TERMINAL:
119
133
  runs[run["run_id"]] = encode_receipt(runs[run["run_id"]], version=COMPACT_VERSION)
120
134
  ledger_usage(runs, compact_version=COMPACT_VERSION)
121
135
  if len(canonical(runs).encode("utf-8")) > MAX_LEDGER_BYTES:
@@ -184,6 +198,9 @@ def public_result(run: dict[str, Any], *, calls: int = 0) -> dict[str, Any]:
184
198
  "request_kind": run.get("request_kind", "automatic"),
185
199
  **({"intent_id": run["authorization_intent"]} if "authorization_intent" in run else {}),
186
200
  "code": run.get("code"), "commit_work_id": run["commit_work_id"],
201
+ **({"http_status": run["attempts"][-1]["http_status"]} if run["attempts"] and "http_status" in run["attempts"][-1] else {}),
202
+ **({"terminal_recovery": {"expected_revision": run["terminal_recovery"]["expected_revision"],
203
+ "previous_code": run["terminal_recovery"]["previous_code"]}} if "terminal_recovery" in run else {}),
187
204
  "model_calls_this_invocation": calls,
188
205
  "model_calls_known": sum(a["outcome"] != "unknown" for a in run["attempts"]),
189
206
  "reserved_requests": run["reserved_requests"], "request_limit": 2,
@@ -28,6 +28,7 @@ MODEL_ERROR_CODES = frozenset(
28
28
  }
29
29
  )
30
30
  MODEL_ERROR_STAGES = frozenset({"gate", "summarize", "single_pass"})
31
+ HTTP_RETRYABLE_STATUSES = frozenset({408, 500, 502, 503, 504})
31
32
  MODEL_VALIDATION_REASONS = frozenset(
32
33
  {"empty_content", "invalid_json", "schema_violation", "response_shape"}
33
34
  )
@@ -92,6 +93,7 @@ class ModelError(RuntimeError):
92
93
  code: str = "model_failed",
93
94
  stage: str | None = None,
94
95
  validation_reason: str | None = None,
96
+ http_status: int | None = None,
95
97
  ):
96
98
  # Existing callers may still provide a descriptive local message. The
97
99
  # public MCP/marker paths use only ``code`` and ``stage``.
@@ -99,6 +101,9 @@ class ModelError(RuntimeError):
99
101
  self.code = _safe_error_code(code)
100
102
  self.stage = _safe_error_stage(stage)
101
103
  self.validation_reason = _safe_validation_reason(validation_reason)
104
+ # Status alone is safe to retain; never retain the provider response,
105
+ # URL, headers or free-form HTTP reason in a diagnostic receipt.
106
+ self.http_status = http_status if type(http_status) is int and 100 <= http_status <= 599 else None
102
107
  self.response_diagnostics: dict[str, Any] = {}
103
108
 
104
109
  def with_stage(self, stage: str | None) -> "ModelError":
@@ -322,6 +327,7 @@ class HTTPModelBackend:
322
327
  close()
323
328
  except urllib.error.HTTPError as error:
324
329
  status = getattr(error, "code", None)
330
+ status = status if type(status) is int and 100 <= status <= 599 else None
325
331
  if status in (401, 403):
326
332
  code = "model_auth_failed"
327
333
  message = "model authentication failed"
@@ -335,7 +341,7 @@ class HTTPModelBackend:
335
341
  error.close()
336
342
  except Exception:
337
343
  pass
338
- raise ModelError(message, code=code, stage=stage) from None
344
+ raise ModelError(message, code=code, stage=stage, http_status=status) from None
339
345
  except TimeoutError:
340
346
  raise ModelError("model request timed out", code="model_timeout", stage=stage) from None
341
347
  except urllib.error.URLError as error:
@@ -318,6 +318,18 @@ _TOOLS: tuple[dict[str, Any], ...] = (
318
318
  }
319
319
  ),
320
320
  },
321
+ {
322
+ "name": "recover_failed_run",
323
+ "description": (
324
+ "Preview a failed automatic transport run without writes or model calls. "
325
+ "Only an explicit recovery request may set apply=true with the exact preview expected_revision. "
326
+ "Preserves original request budget; allow_legacy_http explicitly acknowledges an unrecorded old HTTP status."
327
+ ),
328
+ "inputSchema": _object_schema({
329
+ "run_id": {"type": "string"}, "apply": {"type": "boolean"},
330
+ "expected_revision": {"type": "string"}, "allow_legacy_http": {"type": "boolean"},
331
+ }, required=["run_id"]),
332
+ },
321
333
  {
322
334
  "name": "process_status",
323
335
  "description": (
@@ -744,6 +756,9 @@ def _safe_model_diagnostics(error: BaseException, *, default_reason: str | None
744
756
  if not isinstance(detail, str) or detail not in MODEL_VALIDATION_DETAILS:
745
757
  detail = "other_schema_violation" if isinstance(error, ModelOutputError) else None
746
758
  fields: dict[str, Any] = {}
759
+ http_status = getattr(error, "http_status", None)
760
+ if type(http_status) is int and 100 <= http_status <= 599:
761
+ fields["http_status"] = http_status
747
762
  if reason is not None:
748
763
  fields["validation_reason"] = reason
749
764
  if detail is not None:
@@ -754,6 +769,9 @@ def _safe_model_diagnostics(error: BaseException, *, default_reason: str | None
754
769
 
755
770
 
756
771
  def _tool_error(error: BaseException) -> dict[str, Any]:
772
+ from .incremental_failed_recovery import FailedRecoveryError
773
+ if isinstance(error, FailedRecoveryError):
774
+ return _tool_result({"status": "error", "error": {"code": error.code, "message": "failed run recovery rejected"}}, is_error=True)
757
775
  if isinstance(error, (RetrievalError, RetrievalGateError)):
758
776
  return _tool_result(
759
777
  {"status": "error", "error": {"code": error.code, "message": str(error)}},
@@ -1070,6 +1088,9 @@ def _invoke_tool(
1070
1088
  value = HostRuntime(service, source).process(**args)
1071
1089
  else:
1072
1090
  value = service.process(**args)
1091
+ elif name == "recover_failed_run":
1092
+ value = service.recover_failed_run(args["run_id"], dry_run=not args.get("apply", False),
1093
+ expected_revision=args.get("expected_revision"), allow_legacy_http=args.get("allow_legacy_http", False))
1073
1094
  elif name == "process_status":
1074
1095
  job_id = args.get("job_id")
1075
1096
  if isinstance(job_id, str) and job_id:
@@ -151,6 +151,7 @@ def _row(turn, result):
151
151
  "turn_index": turn.turn_index, "run_id": result.get("run_id"),
152
152
  "work_id": result.get("commit_work_id"), "execution_status": result["execution_status"],
153
153
  "code": result.get("code"), "memory_ids": ids,
154
+ **({"http_status": result["http_status"]} if "http_status" in result else {}),
154
155
  "model_calls": result.get("model_calls_this_invocation", 0),
155
156
  "reservations": result.get("reserved_requests", 0),
156
157
  "unresolved_evidence_count": len(unresolved_refs),
@@ -325,7 +326,8 @@ def health_view(vault) -> dict[str, Any]:
325
326
  pending_commits = sum(commit_result(work)["execution_status"] == "recovery_required" for work in works)
326
327
  details = [{"run_id": run["run_id"], "source": run["source"], "session_id": run["session_id"],
327
328
  "execution_status": run["status"], "code": run.get("code"),
328
- "reserved_requests": run["reserved_requests"]}
329
+ "reserved_requests": run["reserved_requests"],
330
+ **({"http_status": run["attempts"][-1]["http_status"]} if run["attempts"] and "http_status" in run["attempts"][-1] else {})}
329
331
  for run in runs if run["status"] != "completed"]
330
332
  live = owner_live(state)
331
333
  from .query_progress import retention_inventory
@@ -460,6 +460,14 @@ class Memleaf:
460
460
  context_memory_ids=context_memory_ids,
461
461
  model=model, router=router)
462
462
 
463
+ def recover_failed_run(self, run_id: str, *, dry_run: bool = True,
464
+ expected_revision: str | None = None, allow_legacy_http: bool = False,
465
+ model: Any = None, router: Any = None) -> dict[str, Any]:
466
+ """Preview/apply explicit terminal transport recovery using original budget."""
467
+ from .incremental_failed_recovery import recover_failed_run
468
+ return recover_failed_run(self, run_id, dry_run=dry_run, expected_revision=expected_revision,
469
+ allow_legacy_http=allow_legacy_http, model=model, router=router)
470
+
463
471
  def resume_incremental_run(self, run_id: str, *, backend: Any = None) -> dict[str, Any]:
464
472
  """Resume a frozen response/commit without another model request when possible."""
465
473
  from .incremental_execution import resume_incremental_run