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
27 changes: 27 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,33 @@ loopx todo add \
--action-kind monitor
```

`watch_only=true` changes convergence and replan semantics, not schedulability.
A scheduled watch-only monitor remains eligible at `next_due_at`, but it never
creates autonomous replan pressure and never preempts runnable advancement.
When both are present, `interaction_contract` keeps advancement primary and
projects an optional, typed, no-spend `auxiliary_monitor_poll` route.
That CLI route is available only when it is bound to the current Turn. It
requires the caller to place the fresh observation digest in
`LOOPX_MONITOR_RESULT_HASH` and exposes separate unchanged and material-change
commands; omitting either the Turn binding or result digest fails closed before
monitor writeback.
The canonical watch-only/ordinary-due partition is produced inside the existing
TypeScript Todo summary and quota-planning owners after Agent scope and
capability admission; Python compatibility code only adapts legacy facts and
renders the selected CLI/Lark route.

`watch_only=true` 改变的是收敛与 replan 语义,而不是可调度性。带
`next_due_at` 的 watch-only monitor 到期后仍可轮询,但不会制造 autonomous
replan 压力,也不会抢占 runnable advancement;二者同时存在时,
`interaction_contract` 保持 advancement 为主,并投影一条可选、typed、no-spend
的 `auxiliary_monitor_poll` 路由。
该 CLI 路由仅在绑定当前 Turn 时可用;调用方必须把本次新鲜 observation digest
写入 `LOOPX_MONITOR_RESULT_HASH`,并在 unchanged 与 material-change 两条命令中
明确选择。缺少 Turn 绑定或 result digest 时,monitor writeback 会在写入前失败关闭。
watch-only/普通 due 的权威分区由既有 TypeScript Todo summary 与 quota-planning
owner 在 Agent scope 和 capability admission 之后生成;Python 兼容层只适配旧事实并
渲染已选中的 CLI/Lark 路由。

`--action-kind` is a public-safe token. Known generic tokens such as
`run_eval`, `validate`, `rebuild`, `writeback`, `monitor`, and `poll` help the
CLI project the lane consistently, but explicit `--task-class` is the authority
Expand Down
6 changes: 6 additions & 0 deletions loopx/capabilities/issue_fix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@ should-run` pass can select a matched todo as ordinary runnable work.
Replaying the same merged observation reuses the stable event id and creates no
second transition.

If GitHub redirects a renamed repository, lifecycle reconciliation treats the
provider-returned PR URL as the canonical repository identity and records the
requested repository as an explicit alias source reference. The alias is valid
only for the same observed PR number; resume evaluation remains repository-
qualified and never matches the same number in an unrelated repository.

This is deliberately event-backed rather than webhook-code coupling:

```text
Expand Down
5 changes: 5 additions & 0 deletions loopx/capabilities/issue_fix/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,11 @@ scope、public-safe 且幂等的 `pr_merge` rollout event。Todo resume 投影
后续一次 `status` / `quota should-run` 就能把已匹配的 todo 当作普通 runnable work
选中。相同 merged observation 重放时复用稳定 event id,不会制造第二次 transition。

如果 GitHub 对改名仓库返回重定向,lifecycle reconciliation 以 provider 返回的 PR URL
作为 canonical repository identity,并把请求时的旧仓库记为显式 alias source ref。
alias 只对同一次观察到的 PR number 有效;resume evaluation 仍保持 repository-qualified,
不会误匹配其他仓库中的同号 PR。

这是一条事件驱动链,而不是 webhook 与业务代码硬耦合:

```text
Expand Down
24 changes: 21 additions & 3 deletions loopx/capabilities/issue_fix/pr_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,19 @@ def build_issue_fix_pr_lifecycle_monitor_packet(
reference,
timeout_seconds=fetch_timeout_seconds,
)
canonical_reference = reference
provider_url = payload.get("url")
if isinstance(provider_url, str) and provider_url.strip():
provider_reference = normalise_github_issue_reference(
repo=repo,
issue_ref=pr_ref,
url=provider_url,
)
if (
provider_reference.get("kind") == "pull_request"
and provider_reference.get("number") == reference.get("number")
):
canonical_reference = provider_reference
if not issue_ref:
raw_linked_issues = payload.get("closingIssuesReferences") or payload.get(
"closing_issues_references"
Expand All @@ -958,12 +971,17 @@ def build_issue_fix_pr_lifecycle_monitor_packet(
issue_ref = f"issues_{number}"
break
observation = _build_observation(
repo=str(reference["repo"]),
pr_ref=str(reference["issue_ref"]),
repo=str(canonical_reference["repo"]),
pr_ref=str(canonical_reference["issue_ref"]),
issue_ref=issue_ref,
reference=reference,
reference=canonical_reference,
provider_payload=payload,
)
requested_repo = str(reference["repo"])
canonical_repo = str(canonical_reference["repo"])
if canonical_repo != requested_repo:
observation["requested_repo"] = requested_repo
observation["repository_aliases"] = [requested_repo]
transition = _decide_transition(observation)
maintainer_correction = (
normalise_issue_fix_maintainer_correction_input(maintainer_correction_input)
Expand Down
14 changes: 14 additions & 0 deletions loopx/capabilities/issue_fix/pr_lifecycle_rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,23 @@ def append_pr_merge_rollout_event(
}

pr_ref = f"{repo}#{number}"
repository_aliases = sorted(
{
str(alias).strip().lower()
for alias in observation.get("repository_aliases") or []
if isinstance(alias, str)
and str(alias).strip()
and str(alias).strip().lower() != repo
}
)
event = build_rollout_event(
goal_id=goal_id,
event_kind="pr_merge",
pr_ref=pr_ref,
source_refs=[
{"kind": "pull_request", "ref": f"{alias}#{number}"}
for alias in repository_aliases
],
status="merged",
summary=f"PR {pr_ref} merged; dependent resume conditions may proceed.",
recorded_at=str(
Expand Down Expand Up @@ -82,4 +95,5 @@ def append_pr_merge_rollout_event(
"recorded_at": recorded_event["recorded_at"],
"status": recorded_event.get("status"),
"pr_ref": pr_ref,
"repository_aliases": repository_aliases,
}
52 changes: 40 additions & 12 deletions loopx/control_plane/goals/goal_frontier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,21 @@ def _count_advancement_items(items: Any, *, claimed_by: str | None = None) -> in
def _summary_task_counts(summary: dict[str, Any] | None) -> dict[str, int]:
open_count = _open_todo_count(summary)
if not isinstance(summary, dict):
return {"open": open_count, "advancement": 0, "monitor": 0, "monitor_due": 0}
return {
"open": open_count,
"advancement": 0,
"monitor": 0,
"monitor_due": 0,
"watch_only_monitor_due": 0,
}
executable = summary.get("executable_backlog_items")
monitor_open = summary.get("monitor_open_items")
watch_only_count = (
len(
if isinstance(summary.get("watch_only_monitor_count"), int):
watch_only_count = safe_non_negative_int(
summary.get("watch_only_monitor_count")
)
elif isinstance(monitor_open, list):
watch_only_count = len(
[
item
for item in monitor_open
Expand All @@ -591,10 +601,14 @@ def _summary_task_counts(summary: dict[str, Any] | None) -> dict[str, int]:
and todo_item_is_watch_only_monitor(item)
]
)
if isinstance(monitor_open, list)
else safe_non_negative_int(summary.get("watch_only_monitor_count"))
)
open_count = max(0, open_count - watch_only_count)
else:
watch_only_count = 0
if isinstance(summary.get("convergence_open_count"), int):
open_count = safe_non_negative_int(summary.get("convergence_open_count"))
else:
# Compatibility-only fallback for summaries produced before the typed
# TypeScript lane owner exposed convergence_open_count.
open_count = max(0, open_count - watch_only_count)
advancement_count = (
_count_advancement_items(executable)
if isinstance(executable, list)
Expand All @@ -608,8 +622,15 @@ def _summary_task_counts(summary: dict[str, Any] | None) -> dict[str, int]:
]
)
)
monitor_count = (
len(
work_counts = summary.get("work_counts")
if isinstance(work_counts, dict):
monitor_count = max(
0,
safe_non_negative_int(work_counts.get("monitor"))
- watch_only_count,
)
elif isinstance(monitor_open, list):
monitor_count = len(
[
item
for item in monitor_open
Expand All @@ -619,9 +640,10 @@ def _summary_task_counts(summary: dict[str, Any] | None) -> dict[str, int]:
and not todo_item_is_watch_only_monitor(item)
]
)
if isinstance(monitor_open, list)
else safe_non_negative_int(summary.get("claimed_monitor_open_count"))
)
else:
monitor_count = safe_non_negative_int(
summary.get("claimed_monitor_open_count")
)
return {
"open": open_count,
"advancement": advancement_count,
Expand All @@ -631,6 +653,9 @@ def _summary_task_counts(summary: dict[str, Any] | None) -> dict[str, int]:
safe_non_negative_int(summary.get("monitor_due_count"))
- safe_non_negative_int(summary.get("watch_only_monitor_due_count")),
),
"watch_only_monitor_due": safe_non_negative_int(
summary.get("watch_only_monitor_due_count")
),
}


Expand Down Expand Up @@ -1812,6 +1837,9 @@ def build_goal_frontier_projection(
"agent_advancement_open_count": agent_counts.get("advancement", 0),
"agent_monitor_open_count": agent_counts.get("monitor", 0),
"agent_monitor_due_count": agent_counts.get("monitor_due", 0),
"agent_watch_only_monitor_due_count": agent_counts.get(
"watch_only_monitor_due", 0
),
},
"remaining_advancement_frontier": {
"current_agent_claimed_advancement_count": current_agent_claimed_advancement_count,
Expand Down
4 changes: 3 additions & 1 deletion loopx/control_plane/todos/quota_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
)
from .todo_semantics import (
todo_item_has_removed_continuation_policy, todo_item_is_actionable_open,
todo_item_is_due_monitor, todo_item_task_class, todo_projection_sort_key,
todo_item_is_due_monitor, todo_item_is_watch_only_monitor,
todo_item_task_class, todo_projection_sort_key,
todo_summary_monitor_writeback_supported,
)
from .resume_planning import build_todo_resume_planning_request
Expand Down Expand Up @@ -46,6 +47,7 @@ def encode(item: dict[str, Any]) -> dict[str, Any]:
"removed": todo_item_has_removed_continuation_policy(item),
"actionable": todo_item_is_actionable_open(item),
"due": todo_item_is_due_monitor(item),
"watch_only": todo_item_is_watch_only_monitor(item),
"task_class": todo_item_task_class(item),
"priority": priority, "index": index,
"profile_rank": agent_profile_candidate_rank(item, agent_profile=profile),
Expand Down
12 changes: 9 additions & 3 deletions loopx/control_plane/todos/quota_selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface Row {
payload: JsonObject; display: JsonObject; claim: string | null;
bound: string | null; blocks: string | null; excluded: readonly string[];
global: boolean; gate: boolean; removed: boolean; actionable: boolean;
due: boolean; taskClass: string; priority: number; index: number;
due: boolean; watchOnly: boolean; taskClass: string; priority: number; index: number;
profileRank: number; missing: readonly string[]; rawClaimed: boolean;
}

Expand All @@ -25,7 +25,8 @@ function decodeRow(value: unknown, available?: readonly string[]): Row {
claim: optional("claim"), bound: optional("bound"), blocks: optional("blocks"),
excluded: requireStringArray(raw.excluded, "excluded"), global: boolean("global"),
gate: boolean("gate"), removed: boolean("removed"), actionable: boolean("actionable"),
due: boolean("due"), taskClass: optional("task_class") ?? "advancement_task",
due: boolean("due"), watchOnly: raw.watch_only === undefined ? false : boolean("watch_only"),
taskClass: optional("task_class") ?? "advancement_task",
priority: integer("priority"), index: integer("index"), profileRank: integer("profile_rank"),
missing: available === undefined ? requireStringArray(raw.missing, "missing") :
missingRequiredCapabilities(requireStringArray(raw.required, "required"), requireStringArray(raw.targets, "targets"), available),
Expand Down Expand Up @@ -148,6 +149,8 @@ export function projectQuotaSelection(value: unknown): JsonObject {
const scope = agent && !userMode ? claimScope(blocking, open, agent, profile, diagnostic) : null;
const monitors = open.filter(row => row.actionable && row.taskClass === "continuous_monitor");
const due = supported ? monitors.filter(row => row.due && executableBy(row, agent)) : [];
const admittedDue = due.filter(row => !row.missing.length);
const watchOnlyMonitors = monitors.filter(row => row.watchOnly);
const activeVisible = (row: Row) => userMode ? (row.gate ? gateApplies(row, agent) : actionApplies(row, agent)) : executableBy(row, agent);
const gateFilter = otherGates.length ? {
schema_version: "agent_scoped_user_gate_filter_v0", agent_id: agent,
Expand All @@ -170,7 +173,10 @@ export function projectQuotaSelection(value: unknown): JsonObject {
user_action_agent_scope_filter: actionFilter, other_agent_scoped_items: payloads(otherGates),
agent_scope_filter: gateFilter, open_items: payloads(open), claim_scope: scope,
executable_items: payloads(open.filter(row => row.actionable && row.taskClass === "advancement_task")),
monitor_items: payloads(monitors), monitor_due_items: payloads(due.filter(row => !row.missing.length)),
monitor_items: payloads(monitors), monitor_due_items: payloads(admittedDue),
watch_only_monitor_items: payloads(watchOnlyMonitors),
watch_only_monitor_due_items: payloads(admittedDue.filter(row => row.watchOnly)),
non_watch_only_monitor_due_items: payloads(admittedDue.filter(row => !row.watchOnly)),
monitor_capability_blocked_due_items: due.filter(row => row.missing.length).map(row => ({...row.display, missing_capabilities: [...row.missing]})),
claimed_open_items: payloads(blocking.filter(row => row.rawClaimed)),
display_open_items: payloads(userMode ? [...open, ...actions] : open),
Expand Down
27 changes: 22 additions & 5 deletions loopx/control_plane/todos/quota_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
)
QUOTA_PAYLOAD_LANE_LIMITS = {
"monitor_due_items": MONITOR_DUE_ITEM_LIMIT,
"watch_only_monitor_due_items": MONITOR_DUE_ITEM_LIMIT,
"non_watch_only_monitor_due_items": MONITOR_DUE_ITEM_LIMIT,
"monitor_capability_blocked_due_items": QUOTA_PAYLOAD_DIAGNOSTIC_LANE_LIMIT,
"monitor_schedule_gap_items": MONITOR_DUE_ITEM_LIMIT,
"first_open_items": 3,
Expand Down Expand Up @@ -181,6 +183,9 @@ class _QuotaTodoLanes:
executable_items: list[dict[str, Any]]
monitor_items: list[dict[str, Any]]
monitor_due_items: list[dict[str, Any]]
watch_only_monitor_items: list[dict[str, Any]]
watch_only_monitor_due_items: list[dict[str, Any]]
non_watch_only_monitor_due_items: list[dict[str, Any]]
monitor_capability_blocked_due_items: list[dict[str, Any]]
claimed_open_items: list[dict[str, Any]]
display_open_items: list[dict[str, Any]]
Expand Down Expand Up @@ -401,6 +406,14 @@ def summarize_user_todos_for_quota(
"monitor_open_items": lanes.monitor_items,
"monitor_due_count": len(lanes.monitor_due_items),
"monitor_due_items": lanes.monitor_due_items[:MONITOR_DUE_ITEM_LIMIT],
"watch_only_monitor_count": len(lanes.watch_only_monitor_items),
"watch_only_monitor_due_count": len(lanes.watch_only_monitor_due_items),
"watch_only_monitor_due_items": lanes.watch_only_monitor_due_items[
:MONITOR_DUE_ITEM_LIMIT
],
"non_watch_only_monitor_due_items": lanes.non_watch_only_monitor_due_items[
:MONITOR_DUE_ITEM_LIMIT
],
"monitor_capability_blocked_due_count": len(
lanes.monitor_capability_blocked_due_items
),
Expand All @@ -420,11 +433,7 @@ def summarize_user_todos_for_quota(
summary["advancement_frontier_revision_index"] = value[
"advancement_frontier_revision_index"
]
if value.get("watch_only_monitor_count"):
summary["watch_only_monitor_count"] = value["watch_only_monitor_count"]
summary["watch_only_monitor_due_count"] = value.get(
"watch_only_monitor_due_count", 0
)
if lanes.watch_only_monitor_items:
summary["convergence_open_count"] = value.get("convergence_open_count")
if recent_completed_advancement_items:
summary["recent_completed_advancement_items"] = recent_completed_advancement_items
Expand Down Expand Up @@ -821,6 +830,14 @@ def summarize_project_asset_todos_for_quota(
"monitor_open_items": lanes.monitor_items,
"monitor_due_count": len(lanes.monitor_due_items),
"monitor_due_items": lanes.monitor_due_items[:MONITOR_DUE_ITEM_LIMIT],
"watch_only_monitor_count": len(lanes.watch_only_monitor_items),
"watch_only_monitor_due_count": len(lanes.watch_only_monitor_due_items),
"watch_only_monitor_due_items": lanes.watch_only_monitor_due_items[
:MONITOR_DUE_ITEM_LIMIT
],
"non_watch_only_monitor_due_items": lanes.non_watch_only_monitor_due_items[
:MONITOR_DUE_ITEM_LIMIT
],
"monitor_capability_blocked_due_count": len(
lanes.monitor_capability_blocked_due_items
),
Expand Down
13 changes: 11 additions & 2 deletions loopx/control_plane/todos/summary_lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export const TODO_SUMMARY_LANES = [
"open_items", "terminal_items", "deferred_items", "done_items", "projected_open_items",
"projected_deferred_items", "budgeted_items", "claimed_open_items", "unclaimed_open_items",
"executable_items", "blocker_items", "resume_blocked_items", "monitor_items",
"monitor_due_items", "monitor_schedule_gap_items", "claimed_advancement_items",
"monitor_due_items", "watch_only_monitor_items", "watch_only_monitor_due_items",
"non_watch_only_monitor_due_items", "convergent_open_items",
"monitor_schedule_gap_items", "claimed_advancement_items",
"claimed_monitor_items", "active_next_action_items", "active_next_action_executable_items",
] as const;
export type TodoSummaryLane = typeof TODO_SUMMARY_LANES[number];
Expand Down Expand Up @@ -82,6 +84,9 @@ export function projectTodoSummaryLanes(value: unknown): JsonObject {
const monitors = ordered.filter(row => row.actionable && row.taskClass === "continuous_monitor");
const activeMonitor = (row: Row) => row.expiresAt === null || row.expiresAt > now;
const due = monitors.filter(row => activeMonitor(row) && row.dueAt !== null && row.dueAt <= now);
const watchOnlyMonitors = monitors.filter(row => row.watchOnly);
const watchOnlyDue = due.filter(row => row.watchOnly);
const nonWatchOnlyDue = due.filter(row => !row.watchOnly);
const missing = monitors.filter(row => activeMonitor(row) && !row.watchOnly && row.dueAt === null);
const selected = {
open_items: open, terminal_items: terminal, deferred_items: deferred, done_items: done,
Expand All @@ -90,7 +95,11 @@ export function projectTodoSummaryLanes(value: unknown): JsonObject {
unclaimed_open_items: ordered.filter(row => !row.claim), executable_items: executable,
blocker_items: ordered.filter(row => row.status === "blocked" && row.taskClass === "blocker"),
resume_blocked_items: ordered.filter(row => row.resumeBlocked), monitor_items: monitors,
monitor_due_items: due, monitor_schedule_gap_items: missing,
monitor_due_items: due, watch_only_monitor_items: watchOnlyMonitors,
watch_only_monitor_due_items: watchOnlyDue,
non_watch_only_monitor_due_items: nonWatchOnlyDue,
convergent_open_items: open.filter(row => !(row.taskClass === "continuous_monitor" && row.watchOnly)),
monitor_schedule_gap_items: missing,
claimed_advancement_items: executable.filter(row => row.claim), claimed_monitor_items: monitors.filter(row => row.claim),
active_next_action_items: ordered.filter(row => row.preferred),
active_next_action_executable_items: executable.filter(row => row.preferred),
Expand Down
Loading
Loading