Skip to content

Commit 354ebfc

Browse files
authored
feat(issue-fix): materialize grouped PR monitors (#2725)
* feat(issue-fix): materialize grouped PR monitors * fix(issue-fix): keep monitor command packet compact * test(issue-fix): compare monitor due instants * fix(issue-fix): define grouped monitor ownership
1 parent a893d22 commit 354ebfc

13 files changed

Lines changed: 933 additions & 44 deletions

docs/capabilities/issue-fix/protocols/issue-fix-workflow-contract-v0.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,17 @@ open PRs, merge, publish, or run destructive git without an explicit gate.
125125
`continuous_monitor` for each nonempty bucket, upsert/remove PR membership
126126
as state changes, and complete empty buckets. Never create one monitor per
127127
PR. Material PR work remains a one-shot advancement todo, and reviewer
128-
notifications remain one PR per message.
128+
notifications remain one PR per message. `pr-lifecycle
129+
--execute-transition --goal-id <goal> --claimed-by <agent>` performs this
130+
reconciliation through the generic todo API; `--monitor-cadence` controls
131+
the schedule and defaults to `30m`. A quiet replay with unchanged bucket
132+
membership is idempotent. The monitor poll lane, rather than repeated PR
133+
lifecycle execution, owns later cadence advancement. The creating issue-fix
134+
agent remains the monitor's `claimed_by` owner across turns; another peer
135+
cannot update, retire, reopen, or poll that monitor without explicit Todo
136+
lifecycle authority.
137+
With a public PR URL, `--execute-transition` fetches compact public metadata
138+
automatically unless `--metadata-json` supplies a deterministic fixture.
129139
11. **Gate handling:** surface concrete gates instead of silently blocking. Safe
130140
metadata-only triage, public-code search, and focused smoke drafting may
131141
continue when those gates do not cover the selected action.

docs/capabilities/issue-fix/state-kernel-domain-state-case-study.zh-CN.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,13 @@ metadata;CI pending 只继续 monitor,CI failure 和未处理的 changes req
226226

227227
多个同类 PR 不需要一 PR 一 monitor。`issue_fix_pr_grouped_monitor_projection_v1` 按仓库和
228228
lifecycle bucket 聚合,Kernel 只调度非空 bucket 的一个 monitor;真正的 patch 仍是独立的一次性
229-
advancement todo。
229+
advancement todo。`pr-lifecycle --execute-transition --goal-id <goal> --claimed-by <agent>`
230+
通过通用 Todo API 完成这笔物化事务;`--monitor-cadence` 控制调度周期,缺省为 `30m`
231+
相同 bucket membership 的安静重放保持幂等,后续 cadence 推进由 monitor poll lane 负责,而不是
232+
反复执行 PR lifecycle transition。创建该 monitor 的 issue-fix Agent 会跨 turn 保持为
233+
`claimed_by` owner;其他 peer 未获得显式 Todo lifecycle authority 时不能更新、retire、重开或
234+
poll 该 monitor。给出公开 PR URL 时,`--execute-transition` 会自动读取紧凑的
235+
公开 metadata;只有确定性 fixture 才需要显式传 `--metadata-json`
230236

231237
### 6. Terminal closeout 与继续运行
232238

examples/bootstrap-command-pack-smoke.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ def test_goal_text_invocation_plans_ranked_todos_before_activation() -> None:
231231
assert "--url <github-pr-url>" in pr_lifecycle_template
232232
assert "--goal-id" in pr_lifecycle_template
233233
assert str(payload["goal_id"]) in pr_lifecycle_template
234+
assert "--claimed-by codex-test-agent" in pr_lifecycle_template
235+
assert "--execute-transition" in pr_lifecycle_template
234236
assert "issue_fix_reviewer_request_template" in commands
235237
reviewer_request_template = str(commands["issue_fix_reviewer_request_template"])
236238
assert "issue-fix reviewer-request" in reviewer_request_template

examples/issue-fix-pr-lifecycle-smoke.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def main() -> int:
196196
)
197197
assert blocked_pending["transition"]["material_change"] is False
198198
assert blocked_pending["grouped_monitor_projection"]["target_key"] == (
199-
"github-pr-state-checks-pending"
199+
"github-pr-state-huangruiteng--loopx-checks-pending"
200200
)
201201

202202
stale = build_issue_fix_pr_lifecycle_monitor_packet(
@@ -239,7 +239,7 @@ def main() -> int:
239239
quiet["grouped_monitor_projection"]
240240
| {
241241
"state_bucket": "review_required",
242-
"target_key": "github-pr-state-review-required",
242+
"target_key": "github-pr-state-huangruiteng--loopx-review-required",
243243
"action_kind": "issue_fix_pr_state_review_required_monitor",
244244
"member_key": "huangruiteng/loopx#1715",
245245
"member_operation": "upsert",
@@ -275,7 +275,7 @@ def main() -> int:
275275
assert_packet_shape(approved)
276276
assert approved["grouped_monitor_projection"]["state_bucket"] == "ready_to_merge"
277277
assert approved["grouped_monitor_projection"]["target_key"] == (
278-
"github-pr-state-ready-to-merge"
278+
"github-pr-state-huangruiteng--loopx-ready-to-merge"
279279
)
280280
for alias in ("#1700", "issue_1700", "issues/1700", "issue 1700"):
281281
alias_packet = build_issue_fix_pr_lifecycle_monitor_packet(

examples/issue-fix-workflow-plan-smoke.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ def assert_workflow_shape(payload: dict[str, Any]) -> None:
124124
assert feasibility["writes_loopx_todo"] is False
125125
post_pr = payload["post_pr_lifecycle_monitor_plan"]
126126
assert post_pr["schema_version"] == "issue_fix_post_pr_lifecycle_monitor_plan_v1"
127+
assert "--goal-id <goal-id>" in post_pr["command_preview"]
128+
assert "--claimed-by <agent-id>" in post_pr["command_preview"]
129+
assert "--execute-transition" in post_pr["command_preview"]
130+
assert "--fetch-metadata" not in post_pr["command_preview"]
127131
assert post_pr["creates_per_pr_continuous_monitor_todo"] is False, post_pr
128132
assert post_pr["monitor_scope"] == "lifecycle_state_bucket", post_pr
129133
assert post_pr["materializes_nonempty_buckets_only"] is True, post_pr

loopx/bootstrap_command_pack.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -775,10 +775,6 @@ def build_loopx_bootstrap_command_pack(
775775
mutation_confirmation_required = bool(inspection.get("mutation_confirmation_required"))
776776
normalized_goal_text = " ".join(goal_text.split()) if goal_text else None
777777
explicit_goal_start = bool(normalized_goal_text)
778-
issue_fix_commands = build_issue_fix_goal_command_templates(
779-
cli_bin=cli_bin,
780-
goal_id=resolved_goal_id,
781-
)
782778
issue_fix_hint_commands = build_issue_fix_goal_command_templates(
783779
cli_bin=cli_bin,
784780
goal_id="<goal-id>",
@@ -811,6 +807,11 @@ def build_loopx_bootstrap_command_pack(
811807
available_capabilities=available_capabilities,
812808
)
813809
selected_agent_id = host_loop_activation.get("agent_id")
810+
issue_fix_commands = build_issue_fix_goal_command_templates(
811+
cli_bin=cli_bin,
812+
goal_id=resolved_goal_id,
813+
agent_id=str(selected_agent_id) if selected_agent_id else "<agent-id>",
814+
)
814815
activation_allowed = bool(host_loop_activation.get("activation_allowed"))
815816
activation_commands = host_loop_activation.get("commands")
816817
activation_commands = activation_commands if isinstance(activation_commands, dict) else {}

loopx/capabilities/catalog.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from .registry import CapabilityRegistry
8+
from .issue_fix.workflow_plan import build_issue_fix_pr_lifecycle_command
89

910
from ..extensions.runtime import extension_catalog_entries
1011

@@ -255,9 +256,14 @@
255256
"write_boundary": "verified configured secondary sends plus compact receipt or stale-queue state writeback; no per-PR continuous monitor, arbitrary comment, push, merge, or publish",
256257
},
257258
{
258-
"command": "loopx issue-fix pr-lifecycle --url <github-pr-url> --goal-id <goal-id> --format json",
259-
"purpose": "Project public PR lifecycle state into a successor, monitor continuation, user gate, or no-follow-up transition.",
260-
"write_boundary": "writes compact project-local domain state when goal or ledger context is provided; no external comment, PR creation, merge, raw logs, or body/comment capture",
259+
"command": build_issue_fix_pr_lifecycle_command(
260+
cli_bin="loopx",
261+
goal_id="<goal-id>",
262+
agent_id="<agent-id>",
263+
project="<repo>",
264+
),
265+
"purpose": "Project public PR lifecycle state and reconcile its grouped monitor, successor, user gate, or no-follow-up transition.",
266+
"write_boundary": "reads compact public PR metadata and writes compact project-local domain state plus generic LoopX todos; no external comment, PR creation, merge, raw logs, or body/comment capture",
261267
},
262268
{
263269
"command": "loopx issue-fix outcome --goal-id <goal-id> --repo <owner/repo> --issue-ref <issue-ref> --pr-ref <pr-ref> --format json",

loopx/capabilities/issue_fix/cli.py

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
validate_issue_fix_pr_lifecycle_monitor_packet,
6565
)
6666
from .pr_lifecycle_rollout import append_pr_merge_rollout_event
67+
from .pr_monitor_materialization import (
68+
DEFAULT_ISSUE_FIX_MONITOR_CADENCE,
69+
materialize_issue_fix_grouped_monitors,
70+
)
6771
from .provider_hooks import IssueFixReviewerProviderHooksFactory
6872
from . import pr_gate_reconcile_cli
6973
from .reviewer_cli import (
@@ -620,8 +624,9 @@ def register_issue_fix_commands(
620624
"--execute-transition",
621625
action="store_true",
622626
help=(
623-
"Write the correction transition into the existing LoopX todo state. "
624-
"Requires --goal-id, --claimed-by, --maintainer-correction-json, and a registry."
627+
"Write the lifecycle transition and reconcile grouped monitors in "
628+
"the existing LoopX todo state. Requires --goal-id, --claimed-by, "
629+
"and a registry."
625630
),
626631
)
627632
pr_lifecycle_parser.add_argument(
@@ -632,6 +637,14 @@ def register_issue_fix_commands(
632637
"by the generated concrete user gate."
633638
),
634639
)
640+
pr_lifecycle_parser.add_argument(
641+
"--monitor-cadence",
642+
default=DEFAULT_ISSUE_FIX_MONITOR_CADENCE,
643+
help=(
644+
"Cadence for grouped PR lifecycle continuous monitors materialized "
645+
"by --execute-transition (default: 30m)."
646+
),
647+
)
635648
pr_gate_reconcile_cli.register_pr_gate_reconciliation_command(issue_fix_sub)
636649
outcome_parser = issue_fix_sub.add_parser(
637650
"outcome",
@@ -1231,10 +1244,20 @@ def handle_issue_fix_command(
12311244
raise ValueError(
12321245
"--fetch-metadata cannot be combined with --metadata-json"
12331246
)
1234-
if args.execute_transition and not args.maintainer_correction_json:
1247+
fetch_metadata = bool(
1248+
args.fetch_metadata
1249+
or (args.execute_transition and args.url and not args.metadata_json)
1250+
)
1251+
if args.execute_transition and args.no_write_domain_state:
12351252
raise ValueError(
1236-
"--execute-transition requires --maintainer-correction-json"
1253+
"--execute-transition cannot be combined with --no-write-domain-state"
12371254
)
1255+
if args.execute_transition and registry_path is None:
1256+
raise ValueError("--execute-transition requires a LoopX registry")
1257+
if args.execute_transition and not args.goal_id:
1258+
raise ValueError("--execute-transition requires --goal-id")
1259+
if args.execute_transition and not args.claimed_by:
1260+
raise ValueError("--execute-transition requires --claimed-by")
12381261
payload = build_issue_fix_pr_lifecycle_monitor_packet(
12391262
repo=args.repo,
12401263
pr_ref=args.pr_ref,
@@ -1243,7 +1266,7 @@ def handle_issue_fix_command(
12431266
provider_payload=_load_json_object(args.metadata_json)
12441267
if args.metadata_json
12451268
else None,
1246-
fetch_metadata=args.fetch_metadata,
1269+
fetch_metadata=fetch_metadata,
12471270
fetch_timeout_seconds=args.fetch_timeout_seconds,
12481271
maintainer_correction_input=(
12491272
_load_json_object(args.maintainer_correction_json)
@@ -1288,14 +1311,23 @@ def handle_issue_fix_command(
12881311
)
12891312
transition = payload.get("transition")
12901313
if args.execute_transition:
1291-
if registry_path is None:
1292-
raise ValueError("--execute-transition requires a LoopX registry")
1293-
if not args.goal_id:
1294-
raise ValueError("--execute-transition requires --goal-id")
1295-
if not args.claimed_by:
1296-
raise ValueError("--execute-transition requires --claimed-by")
12971314
if not isinstance(transition, dict):
12981315
raise ValueError("PR lifecycle transition is missing")
1316+
grouped_monitor_writeback = materialize_issue_fix_grouped_monitors(
1317+
registry_path=registry_path,
1318+
goal_id=args.goal_id,
1319+
project=Path(args.project).expanduser(),
1320+
ledger_path=ledger_path,
1321+
claimed_by=args.claimed_by,
1322+
cadence=args.monitor_cadence,
1323+
generated_at=str(payload.get("generated_at") or generated_at),
1324+
)
1325+
payload["grouped_monitor_writeback"] = grouped_monitor_writeback
1326+
grouped_monitor_projection = payload.get("grouped_monitor_projection")
1327+
if isinstance(grouped_monitor_projection, dict):
1328+
grouped_monitor_projection["todo_write_performed"] = bool(
1329+
grouped_monitor_writeback.get("write_performed")
1330+
)
12991331
decision = str(transition.get("decision") or "")
13001332
if decision in {"runnable_successor", "user_gate"}:
13011333
role = str(transition.get("role") or "agent")
@@ -1348,6 +1380,26 @@ def handle_issue_fix_command(
13481380
),
13491381
"path_recorded": False,
13501382
}
1383+
grouped_write_performed = bool(
1384+
grouped_monitor_writeback.get("write_performed")
1385+
)
1386+
transition_write_performed = bool(payload.get("todo_write_performed"))
1387+
payload["todo_write_performed"] = bool(
1388+
grouped_write_performed or transition_write_performed
1389+
)
1390+
if grouped_write_performed and not transition_write_performed:
1391+
payload["todo_write"] = {
1392+
"schema_version": "issue_fix_pr_lifecycle_todo_write_v1",
1393+
"write_performed": True,
1394+
"grouped_monitor_write_performed": True,
1395+
"transition_write_performed": False,
1396+
"path_recorded": False,
1397+
}
1398+
writeback_contract = payload.get("writeback_contract")
1399+
if isinstance(writeback_contract, dict):
1400+
writeback_contract["todo_write_performed"] = bool(
1401+
payload["todo_write_performed"]
1402+
)
13511403
validation = validate_issue_fix_pr_lifecycle_monitor_packet(payload)
13521404
payload["validation"] = validation
13531405
payload["ok"] = bool(validation["ok"])

loopx/capabilities/issue_fix/pr_lifecycle.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,19 @@ def _observation_fingerprint(observation: Mapping[str, Any]) -> str:
570570
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
571571

572572

573+
def issue_fix_grouped_monitor_identity(
574+
*, repository: str, state_bucket: str
575+
) -> tuple[str, str]:
576+
"""Return stable repository-scoped monitor target and action identities."""
577+
578+
repository_key = repository.casefold().replace("/", "--")
579+
bucket_key = state_bucket.replace("_", "-")
580+
return (
581+
f"github-pr-state-{repository_key}-{bucket_key}",
582+
f"issue_fix_pr_state_{state_bucket}_monitor",
583+
)
584+
585+
573586
def _grouped_monitor_projection(
574587
observation: Mapping[str, Any], transition: Mapping[str, Any]
575588
) -> dict[str, Any]:
@@ -608,17 +621,18 @@ def _grouped_monitor_projection(
608621

609622
terminal = state_bucket == "terminal"
610623
member_key = f"{observation.get('repo')}#{observation.get('number')}"
611-
target_key = (
612-
None if terminal else f"github-pr-state-{state_bucket.replace('_', '-')}"
624+
repository = str(observation.get("repo") or "unknown")
625+
target_key, action_kind = issue_fix_grouped_monitor_identity(
626+
repository=repository,
627+
state_bucket=state_bucket,
613628
)
614629
return {
615630
"schema_version": ISSUE_FIX_PR_GROUPED_MONITOR_PROJECTION_SCHEMA_VERSION,
616631
"scope": "repository_pr_lifecycle_state",
632+
"repository": repository,
617633
"state_bucket": state_bucket,
618-
"target_key": target_key,
619-
"action_kind": (
620-
None if terminal else f"issue_fix_pr_state_{state_bucket}_monitor"
621-
),
634+
"target_key": None if terminal else target_key,
635+
"action_kind": None if terminal else action_kind,
622636
"member_key": member_key,
623637
"member_operation": "remove" if terminal else "upsert",
624638
"materialize_nonempty_bucket_monitor": not terminal,
@@ -988,7 +1002,9 @@ def build_issue_fix_pr_lifecycle_monitor_packet(
9881002
"first_screen": {
9891003
"waiting_on": transition["role"],
9901004
"user_action_required": transition["role"] == "user",
991-
"agent_can_continue": transition["decision"] == "runnable_successor",
1005+
"agent_can_continue": transition["decision"] != "user_gate",
1006+
"current_pr_actionable": transition["decision"] == "runnable_successor",
1007+
"immediate_repoll_required": False,
9921008
"next_safe_action": transition["reason"],
9931009
},
9941010
"writeback_contract": {

0 commit comments

Comments
 (0)