Skip to content

Commit 5bdda48

Browse files
wolfgang-auraclaude
andcommitted
wip: close the six PRHunt delay defects
Coordinator lease so one hunt has one owner (#63). Checkpoint packet for candidates that are ready before the quota is met (#64). Review budget counted across resume-review calls (#65). Target assessments offloaded and captured streams capped, which is what made one orchestration record 124 MB (#66). Usage-limit and infrastructure stops classified apart from candidate failures, with the exact resume command (#67). Narrow-first duplicate discovery that stops on a confirmed duplicate (#68). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 56d1e06 commit 5bdda48

11 files changed

Lines changed: 792 additions & 8 deletions

File tree

mailman/cli.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _build_parser() -> argparse.ArgumentParser:
122122
"action",
123123
choices=(
124124
"init", "add", "drop", "restore", "status", "finish", "list",
125-
"escalate", "refresh-procedure",
125+
"escalate", "refresh-procedure", "lease", "release",
126126
),
127127
)
128128
hunt.add_argument("hunt_id", nargs="?")
@@ -132,6 +132,16 @@ def _build_parser() -> argparse.ArgumentParser:
132132
hunt.add_argument(f"--{role}-model")
133133
hunt.add_argument("--reason")
134134
hunt.add_argument("--evidence")
135+
hunt.add_argument(
136+
"--owner",
137+
help="this coordinator's lease token, printed by hunt init. One hunt "
138+
"has one owner; a second coordinator must take it over explicitly",
139+
)
140+
hunt.add_argument(
141+
"--takeover",
142+
action="store_true",
143+
help="take a live lease from another coordinator; needs --reason",
144+
)
135145
hunt.add_argument("--attempted")
136146
hunt.add_argument("--why-user")
137147
hunt.add_argument("--user-action")
@@ -288,6 +298,14 @@ def _build_parser() -> argparse.ArgumentParser:
288298
)
289299
duplicate.add_argument("run_id")
290300
duplicate.add_argument("--query", required=True)
301+
duplicate.add_argument(
302+
"--symbol",
303+
action="append",
304+
dest="symbols",
305+
default=[],
306+
help="a function, class or file the change touches, repeatable. "
307+
"Searched with the issue number before the broad listing",
308+
)
291309
duplicate.add_argument("--limit", type=int, default=30)
292310
duplicate.add_argument("--executable")
293311
duplicate.add_argument("--timeout", type=float, default=60)
@@ -480,6 +498,13 @@ def _build_parser() -> argparse.ArgumentParser:
480498
help="how hard a Codex model is asked to think, recorded with the run",
481499
)
482500
orchestrate_parser.add_argument("--max-revisions", type=int, default=1)
501+
orchestrate_parser.add_argument(
502+
"--max-review-cycles",
503+
type=int,
504+
default=3,
505+
help="reviewer passes this run may spend in total, counted across "
506+
"every orchestrate and resume-review call",
507+
)
483508
orchestrate_parser.add_argument(
484509
"--acknowledge-prior-attempts",
485510
action="store_true",
@@ -665,7 +690,8 @@ def _hunt(arguments: argparse.Namespace) -> int:
665690
raise ValueError("ask for primary and reviewer model IDs, then pass all four model flags")
666691
record = hunt.create_hunt(root, int(arguments.hunt_id), primary=arguments.primary,
667692
primary_model=arguments.primary_model, reviewer=arguments.reviewer,
668-
reviewer_model=arguments.reviewer_model)
693+
reviewer_model=arguments.reviewer_model,
694+
owner=arguments.owner)
669695
print(json.dumps(record, indent=2))
670696
return 0
671697
if arguments.action == "refresh-procedure":
@@ -677,6 +703,19 @@ def _hunt(arguments: argparse.Namespace) -> int:
677703
record["procedure_sha256"] = hashlib.sha256(hunt.PROCEDURE.read_bytes()).hexdigest()
678704
hunt.save(path, record)
679705
record = hunt.load_hunt(root, arguments.hunt_id)
706+
if arguments.action == "lease":
707+
lease = hunt.acquire_lease(
708+
root, record, owner=arguments.owner,
709+
takeover_reason=arguments.reason if arguments.takeover else None,
710+
)
711+
print(json.dumps(lease, indent=2))
712+
return 0
713+
if arguments.action == "release":
714+
hunt.release_lease(root, record, owner=arguments.owner)
715+
print(json.dumps({"hunt_id": record["hunt_id"], "lease": None}, indent=2))
716+
return 0
717+
if arguments.action in ("add", "drop", "restore", "escalate", "finish"):
718+
hunt.require_lease(record, arguments.owner)
680719
if arguments.action == "add":
681720
if not arguments.run_id:
682721
raise ValueError("provide the run ID to add")
@@ -901,12 +940,14 @@ def _duplicate_search(arguments: argparse.Namespace) -> int:
901940
executable=arguments.executable,
902941
timeout_seconds=arguments.timeout,
903942
limit=arguments.limit,
943+
symbols=arguments.symbols,
904944
)
905945
print(
906946
json.dumps(
907947
{
908948
"run_id": run.run_id,
909949
"repository": record["repository"],
950+
"decided_by": record.get("decided_by"),
910951
"query": record["query"],
911952
"success": record["success"],
912953
"complete": record["complete"],
@@ -1409,6 +1450,7 @@ def _orchestrate(arguments: argparse.Namespace) -> int:
14091450
agent_timeout_seconds=arguments.agent_timeout,
14101451
verification_timeout_seconds=arguments.verification_timeout,
14111452
max_revisions=arguments.max_revisions,
1453+
max_review_cycles=arguments.max_review_cycles,
14121454
announce=_emit,
14131455
acknowledge_prior_attempts=arguments.acknowledge_prior_attempts,
14141456
acknowledge_claims=arguments.acknowledge_claims,

mailman/executor.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,19 @@ class CommandResult:
2727
environment: dict[str, str]
2828

2929
def to_dict(self) -> dict[str, object]:
30-
return asdict(self)
30+
"""The recorded form of this result, with captured streams capped.
31+
32+
`to_dict` is only ever written to an evidence file; every reader that
33+
needs the whole stream reads `stdout` or `stderr` directly. Capping
34+
here stops one agent transcript from making a record unreadable. See
35+
https://github.com/wolfgang-aura/Mailman/issues/66.
36+
"""
37+
from mailman.limits import truncate_stream
38+
39+
record = asdict(self)
40+
for key in ("stdout", "stderr"):
41+
record[key] = truncate_stream(record[key])
42+
return record
3143

3244

3345
def _environment_metadata() -> dict[str, str]:

mailman/health.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Run health states a coordinator must not mistake for a candidate failure.
2+
3+
Both agent tasks in hunt `20260907T164341Z-1ca91a` stopped mid-stage when the
4+
shared model allowance ran out. Neither left a machine-readable resume point,
5+
so the next session reconstructed stage state from run directories by hand.
6+
7+
See https://github.com/wolfgang-aura/Mailman/issues/67.
8+
"""
9+
from __future__ import annotations
10+
11+
import json
12+
import re
13+
from pathlib import Path
14+
15+
from mailman.models import utc_now
16+
17+
HEALTH_FILENAME = "health.json"
18+
19+
USAGE_LIMIT = "USAGE_LIMIT"
20+
INFRASTRUCTURE = "INFRASTRUCTURE"
21+
22+
#: Phrases a provider CLI prints when the account, not the candidate, is what
23+
#: stopped the stage. Matched case-insensitively against the agent's stop
24+
#: reason and captured streams.
25+
_USAGE_PATTERNS = (
26+
r"usage limit",
27+
r"rate limit",
28+
r"quota (?:exceeded|exhausted)",
29+
r"insufficient (?:credit|quota)",
30+
r"upgrade your plan",
31+
r"try again at\b",
32+
r"429\b",
33+
)
34+
35+
#: The host, not the code under test, failed. A temporary directory that
36+
#: cannot be created is not a defect in the candidate.
37+
_INFRASTRUCTURE_PATTERNS = (
38+
r"permission denied: .*(?:temp|tmp)",
39+
r"\[Errno 13\]",
40+
r"WinError 5\b",
41+
r"no space left on device",
42+
r"could not create cache",
43+
)
44+
45+
_USAGE = re.compile("|".join(_USAGE_PATTERNS), re.IGNORECASE)
46+
_INFRASTRUCTURE = re.compile("|".join(_INFRASTRUCTURE_PATTERNS), re.IGNORECASE)
47+
48+
49+
def classify(*texts: str | None) -> str | None:
50+
"""Name the non-candidate cause in these texts, if there is one."""
51+
joined = "\n".join(text for text in texts if text)
52+
if not joined:
53+
return None
54+
if _USAGE.search(joined):
55+
return USAGE_LIMIT
56+
if _INFRASTRUCTURE.search(joined):
57+
return INFRASTRUCTURE
58+
return None
59+
60+
61+
def record(run_directory: Path, *, state: str, stage: str, resume_command: str,
62+
detail: str) -> Path:
63+
destination = run_directory / HEALTH_FILENAME
64+
destination.write_text(
65+
json.dumps(
66+
{
67+
"schema_version": 1,
68+
"state": state,
69+
"stage": stage,
70+
"resume_command": resume_command,
71+
"detail": detail,
72+
"at": utc_now(),
73+
},
74+
indent=2,
75+
)
76+
+ "\n",
77+
encoding="utf-8",
78+
)
79+
return destination
80+
81+
82+
def load(run_directory: Path) -> dict | None:
83+
path = run_directory / HEALTH_FILENAME
84+
if not path.is_file():
85+
return None
86+
try:
87+
return json.loads(path.read_text(encoding="utf-8"))
88+
except (OSError, json.JSONDecodeError):
89+
return None
90+
91+
92+
def clear(run_directory: Path) -> None:
93+
(run_directory / HEALTH_FILENAME).unlink(missing_ok=True)

0 commit comments

Comments
 (0)