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
21 changes: 21 additions & 0 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -2025,6 +2025,27 @@ unknown review stays pending and cannot become another review or business retry.
Other malformed actions and completed Turns without a recovered native result
remain explicit holds; this path is not a generic action or execution replayer.

### Completed private project result reviews

A completed original project Turn with typed `unfinished_work` can fall back to
one restricted result review when its effects cannot qualify writable
continuation. The provider must read the same stable, closed native final and
unfinished declaration; later native inputs must be correlated to Core. This
observation does **not** certify settled effects. A failed or signal-terminated
command cannot acquire replay authority through this route.

The original worker uses its existing Session, queue and project binding. Fresh
source and grant checks, an idle queue, the backoff, and stop/steering/attachment
guards still apply. A live adapter with owned background work is retained. The
review uses the existing workspace-only read permission profile without network
access; it may inspect current local artifacts and receipts but cannot write,
send, delegate or replace the original work declaration. References in the old
question use its original-time Core context. The original partial Turn and
delivery remain immutable, and the same Lark worker returns the new answer in a
separate, idempotent phase. Reservation crashes adopt that exact queued review.
The retained business obligation stays pending independently of the review's
prose or failure; a review never starts a writable retry or another review.

### File-backed idle recovery observations

Repeated recovery checks must not reparse every historical Turn and source body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,21 @@ worker 使用独立展示阶段返回这些回执,保留原失败 Turn、失
记为欠办,不能变成又一次复核或业务重试。其他错误动作、没有已恢复原生结果的
completed Turn 继续明确保留,本路径不提供通用动作或执行重放。

### 已完成的普通项目部分答复复核

原普通项目 Turn 已完成且包含 typed `unfinished_work` 时,如果执行效果不能支持
可写续办,可以退回一次受限的结果复核。provider 必须读回同一份稳定、已结束的
原生最终答复和未完声明,后续原生输入也必须能关联到 Core。该观测**不证明效果
已经结清**;失败或被信号终止的命令不能借此取得重放权限。

原 worker 复用已有 Session、队列和项目 binding,仍核验新鲜来源和授权、空闲队列、
等待间隔,以及停止、steering 和附件边界。仍持有后台工作的 live adapter 必须保留。
复核使用现有工作区只读权限配置,禁用网络;可检查当前本地产物和回执,不能写入、
发送、委派或替换原未完声明。旧问题中的指代仅使用原提问之前的 Core 上下文。
原部分答复 Turn 和投递保持不变,由同一个飞书 worker 使用独立、幂等阶段补回新
答复;预约崩溃只采用同一个已入队复核。原业务义务独立于复核正文或失败继续保留
为待办;复核不能触发可写重试或又一次复核。

### File 后端的空闲恢复观测

逐条检查旧请求时,不应反复解析全部历史 Turn 和来源正文。File Chat provider
Expand Down
72 changes: 70 additions & 2 deletions loopx/capabilities/native_chat/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,67 @@ def turns():
return None


def completed_result_review_readback(session: CodexChatAgentSession, turn_id: str, *,
known_turn_ids: set[str]) -> dict[str, Any] | None:
"""Read a closed partial result, never certify its effects for execution.

A failed command (including a signal exit) is evidence to inspect, not
permission to retry. Only the existing restricted result-review route may
consume this observation. Later inputs must still belong to Core.
"""
from ...chat import parse_agent_response

deadline = time.monotonic() + min(session.response_timeout_sec, 5.0)
try:
def turns() -> list[dict[str, Any]]:
return list(session._control_items("thread/turns/list", {
"sortDirection": "desc", "itemsView": "summary",
}, deadline))

inventory = turns()
ids = [row.get("id") for row in inventory]
target = next((row for row in inventory if row.get("id") == turn_id), None)
if (target is None or target.get("status") != "completed"
or any(not isinstance(ref, str) or not ref for ref in ids) or len(set(ids)) != len(ids)
or any(row.get("status") not in {"completed", "failed", "interrupted"} for row in inventory)
or any(ref not in known_turn_ids for ref in ids[:ids.index(turn_id)])):
return None
items = list(session._control_items("thread/items/list", {"turnId": turn_id}, deadline))
seen, finals, users = set(), [], 0
for row in items:
item = row.get("item")
if (row.get("turnId") != turn_id or not isinstance(item, dict)
or not isinstance(item.get("id"), str) or not item["id"] or item["id"] in seen):
return None
seen.add(item["id"])
kind = item.get("type")
if kind == "userMessage":
users += 1
elif kind == "agentMessage":
if item.get("phase") == "final_answer" and isinstance(item.get("text"), str):
finals.append(item["text"])
elif item.get("phase") != "commentary":
return None
elif kind in {"commandExecution", "fileChange", "mcpToolCall"}:
if item.get("status") not in {"completed", "failed", "declined"}:
return None
elif kind not in _READ_ONLY_ITEM_TYPES:
return None
if users != 1 or len(finals) != 1:
return None
work = parse_agent_response(finals[0], protected_paths=[session.work_dir]).get("unfinished_work")
if (work is None or turns() != inventory
or list(session._control_items("thread/items/list", {"turnId": turn_id}, deadline)) != items):
return None
return {"thread_id": session.thread_id, "turn_id": turn_id,
"read_only_review": True, "completed_result_review": True,
"unfinished_work": work, "visible_response": finals[0],
"native_inventory_sha256": hashlib.sha256(json.dumps([target, items],
sort_keys=True, separators=(",", ":")).encode()).hexdigest()}
except (CodexChatAgentError, OSError, TypeError, ValueError):
return None


def interrupted_result_readback(session: CodexChatAgentSession, turn_id: str) -> dict[str, Any] | None:
"""Prove a stable interrupted inventory, not that its effects are safe to retry.

Expand Down Expand Up @@ -483,6 +544,8 @@ def inventory():
def failed_request_observation(*, codex_bin: str, codex_home: Path,
thread_ids: list[str], turn_id: str,
read_only_review: bool = False,
completed_result_review: bool = False,
known_turn_ids: set[str] | None = None,
work_dir: Path | None = None,
model_auth_home: Path | None = None) -> dict[str, Any] | None:
"""Read persisted native metadata/items; create no native conversation.
Expand All @@ -493,6 +556,8 @@ def failed_request_observation(*, codex_bin: str, codex_home: Path,
"""
from ...chat_agent import _reader

if completed_result_review and not read_only_review:
raise ValueError("a completed result observation permits only read-only review")
process = subprocess.Popen([codex_bin, "app-server", "--listen", "stdio://"],
cwd=str(codex_home), env={**os.environ, "CODEX_HOME": str(codex_home)},
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
Expand All @@ -508,8 +573,11 @@ def failed_request_observation(*, codex_bin: str, codex_home: Path,
for thread_id in dict.fromkeys(thread_ids):
native.thread_id = thread_id
native.response_timeout_sec = max(.01, deadline - time.monotonic())
reader = interrupted_result_readback if read_only_review else failed_request_readback
proof = reader(native, turn_id)
if completed_result_review:
proof = completed_result_review_readback(native, turn_id, known_turn_ids=known_turn_ids or set())
else:
reader = interrupted_result_readback if read_only_review else failed_request_readback
proof = reader(native, turn_id)
if proof is not None:
if proof.get("codex_error_info") == "usageLimitExceeded":
# Reuse the optional provider's TS availability owner;
Expand Down
Loading
Loading