Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions loopx/capabilities/decision_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,12 +422,38 @@ available instead of waiting an additional scan interval.
`capture-status` separates active `pending_batch_count`, unresolved
`held_batch_count`, per-source `acquisition_held` and
`semantic_review_completion=not_inferred_from_capture`. `last_checked_at` is
the last attempt, not necessarily a successful scan; host service liveness and
successful-scan timestamps remain separate. No status-only call proves historical
replay or complete decision coverage. Disable capture using the existing profile
the last attempt, not necessarily a successful scan. No status-only call proves
historical replay or complete decision coverage. Disable capture using the existing profile
switch; stop the scheduler before downgrading, since older runtimes do not honor
recovery holds. Retain the spool/receipts rather than treating downgrade as rollback.

#### Source freshness contract

An enabled profile, a healthy `loopx doctor` or a settled projection never
implies fresh sources. Every `prepare-evidence` / `prepare-review` assembly and
every `capture` / `capture-status` result carries `source_freshness`
(`decision_source_freshness_v0`): one row per enabled source with
`last_read_at` (last *successful* read), `staleness_seconds`, the source
`freshness_seconds` window, `status` (`fresh`, `stale`, `never_read`,
`not_scanned`), `failure_streak` and `alert_reasons`. Enabled sources outside
the current scan (for example on-demand sources) appear as `not_scanned`
instead of disappearing. Markdown output marks every alerted row with 🔴.
Consumers must disclose alerted sources before presenting a conclusion as current.

A failed provider attempt updates `last_checked_at` and increments
`failure_streak`, but never advances `last_read_at`. Existing spools migrate in
place; a legacy row whose last attempt succeeded uses that attempt as its last read.

`loopx decision-context capture --execute` records a local host health file
under `<runtime-root>/decision-context/capture-hosts/`. Private hosts calling
`capture_profile_sources` should pass `health_runtime_root` for the same effect.
`loopx doctor` reports the optional `decision_context_capture_hosts_healthy`
check without opening private spools. It alerts when a registered host has not
ticked within `max(2 × interval, interval + 600s)` (for example a scheduler still
pointing at a deleted checkout), when the last tick failed, when the spool is
gone, or when a recorded source is stale or failing. Remove the record of a
deliberately retired host.

## Relationship To Other Capabilities

| Capability | Primary question | Relationship |
Expand Down
24 changes: 23 additions & 1 deletion loopx/capabilities/decision_context/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,11 +361,33 @@ python3 -m pytest -q tests/capabilities/test_decision_context_capture.py

`capture-status` 分开报告 active pending、held 历史、每来源 acquisition hold,
并明确 `semantic_review_completion=not_inferred_from_capture`。`last_checked_at`
是尝试时间,不保证成功;服务存活和最近成功扫描时间仍由 host 独立报告。
是尝试时间,不保证成功。
仅看 status 不能证明历史可重放或决策覆盖完整。停用仍使用原 profile 开关;
降级旧版本前必须停止调度器,因为旧运行时不认识 recovery hold。
保留 spool 与回执,不能把软件降级当成状态回滚。

#### 来源新鲜度合同

profile 已启用、`loopx doctor` 健康或已有结算投影,都不代表来源是新鲜的。
每个 `prepare-evidence` / `prepare-review` 组装结果,以及每次 `capture` /
`capture-status` 输出,都携带 `source_freshness`(`decision_source_freshness_v0`):
每个已启用来源一行,包含 `last_read_at`(最近一次**成功**读取)、`staleness_seconds`、
来源的 `freshness_seconds` 窗口、`status`(`fresh`、`stale`、`never_read`、
`not_scanned`)、`failure_streak` 和 `alert_reasons`。不在本次扫描范围内的已启用来源
(例如按需来源)以 `not_scanned` 出现,不会被静默省略。Markdown 输出对所有告警行标 🔴。
消费方在把结论当作"当前情况"之前,必须先披露告警来源。

provider 读取失败会更新 `last_checked_at` 并累加 `failure_streak`,但绝不推进
`last_read_at`。已有 spool 原地迁移;旧记录若最后一次尝试成功,则以该次尝试作为最近读取。

`loopx decision-context capture --execute` 会在
`<runtime-root>/decision-context/capture-hosts/` 下写入本机 host 健康记录。
直接调用 `capture_profile_sources` 的私有 host 应传入 `health_runtime_root` 获得同样效果。
`loopx doctor` 不打开私有 spool,以可选检查 `decision_context_capture_hosts_healthy`
报告:已登记 host 超过 `max(2 × interval, interval + 600s)` 未 tick(例如调度器仍指向
已删除的 checkout)、最后一次 tick 失败、spool 丢失,或记录中的来源陈旧/持续失败时告警。
主动退役的 host 需删除其记录。

## 与其他能力的关系

| 能力 | 核心问题 | 与 Decision Context 的关系 |
Expand Down
58 changes: 58 additions & 0 deletions loopx/capabilities/decision_context/assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
canonical_context_matches,
opaque_provider_ref,
)
from .freshness import build_source_freshness_report, source_freshness_row
from .packets import build_decision_evidence_packet
from .sources import (
DecisionSourceExactRead,
Expand Down Expand Up @@ -451,6 +452,47 @@ def _verified_changed_facts(
return verified


def _assembly_source_freshness(
*,
collected_sources: Sequence[_CollectedSource],
coverage_sources: Sequence[DecisionSourceSpec],
assembly_time: datetime,
) -> dict[str, Any]:
collected_by_id = {
collected.source.source_id: collected for collected in collected_sources
}
rows = []
for source in coverage_sources:
collected = collected_by_id.get(source.source_id)
if collected is None:
rows.append(
source_freshness_row(
source=source,
observed_at=assembly_time,
last_read_at=None,
last_attempt_status=None,
scanned=False,
)
)
continue
attempt = (
"exact_read_failed" if collected.exact_read_failed else collected.scan.status
)
rows.append(
source_freshness_row(
source=source,
observed_at=assembly_time,
last_read_at=(
assembly_time.isoformat()
if attempt in {"completed", "no_change"}
else None
),
last_attempt_status=attempt,
)
)
return build_source_freshness_report(observed_at=assembly_time, rows=rows)


def _accounted_authority(
evidence: Mapping[str, Any],
) -> tuple[set[tuple[str, str]], set[str]]:
Expand Down Expand Up @@ -558,12 +600,16 @@ def assemble_decision_evidence(
recall_query_summary: str = "current decision evidence",
recall_limit: int = 5,
timeout_seconds: float = 10.0,
coverage_sources: Sequence[DecisionSourceSpec] | None = None,
) -> DecisionContextAssembly:
"""Collect, rebase, and assemble one public-safe decision evidence packet.

Cursor values are returned only as private proposals. A caller may persist
them after a reviewed proposal or explicit semantic no-change result has
been written back and validated. Later outcome observation is separate.
``coverage_sources`` lists every enabled source the freshness projection
must account for; enabled sources outside this scan are reported as
``not_scanned`` instead of being silently omitted.
"""

assembly_time = _timestamp(observed_at, field_name="observed_at")
Expand Down Expand Up @@ -812,6 +858,18 @@ def assemble_decision_evidence(
"source_manifest": manifest,
"source_scan_receipts": source_scan_receipts,
"source_coverage": _source_coverage(collected_sources),
"source_freshness": _assembly_source_freshness(
collected_sources=collected_sources,
coverage_sources=sorted(
{
source.source_id: source
for source in (*(coverage_sources or ()), *enabled_sources)
if source.enabled
}.values(),
key=lambda source: source.source_id,
),
assembly_time=assembly_time,
),
"context_retrieval_receipt": retrieval_receipt,
"evidence_packet": evidence,
"semantic_rebase": {
Expand Down
Loading
Loading