Skip to content

Commit a12a484

Browse files
committed
fix(review): harden target failure boundaries
1 parent 4877afc commit a12a484

10 files changed

Lines changed: 497 additions & 29 deletions

File tree

docs/superpowers/specs/2026-07-15-review-target-resolution-design.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ All Git operations use the existing async host process boundary with argv elemen
117117
The current best-effort Git-context helper deliberately collapses command failures to `None`; the
118118
resolver instead uses a checked wrapper at that same boundary so it can distinguish timeout,
119119
non-zero exit, and unavailable Git while still exposing only safe categorized errors. Reads are
120-
bounded, and every timed-out process is killed and reaped.
120+
bounded. Timeout and cancellation paths make bounded kill, drain, and reap attempts only when a
121+
process handle exists; cleanup failures never replace the primary failure, and a failed host kill
122+
can leave the child unreaped.
121123

122124
Every user-provided ref must pass the shape rules above and resolve with
123125
`git rev-parse --verify --end-of-options <ref>^{commit}`. The explicit option boundary remains even

src/pythinker_code/subagents/git_context.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -108,27 +108,28 @@ async def run_git(
108108
raise ValueError("max_output_bytes must be positive")
109109
proc: HostProcess | None = None
110110
completion: asyncio.Task[tuple[int, tuple[bytes, bool], tuple[bytes, bool]]] | None = None
111+
deadline = asyncio.get_running_loop().time() + timeout
111112
try:
112-
proc = await pythinker_host.exec(
113-
"git",
114-
"--no-pager",
115-
"--no-optional-locks",
116-
"-c",
117-
"core.fsmonitor=false",
118-
"-c",
119-
"log.showSignature=false",
120-
"-C",
121-
cwd,
122-
*args,
123-
)
124-
proc.stdin.close()
125-
completion = asyncio.create_task(_collect_process(proc, max_output_bytes))
126113
try:
127-
returncode, stdout_result, stderr_result = await asyncio.wait_for(
128-
asyncio.shield(completion), timeout=timeout
129-
)
114+
async with asyncio.timeout_at(deadline):
115+
proc = await pythinker_host.exec(
116+
"git",
117+
"--no-pager",
118+
"--no-optional-locks",
119+
"-c",
120+
"core.fsmonitor=false",
121+
"-c",
122+
"log.showSignature=false",
123+
"-C",
124+
cwd,
125+
*args,
126+
)
127+
proc.stdin.close()
128+
completion = asyncio.create_task(_collect_process(proc, max_output_bytes))
129+
returncode, stdout_result, stderr_result = await asyncio.shield(completion)
130130
except TimeoutError as exc:
131-
await _cleanup_process(proc, completion)
131+
if proc is not None:
132+
await _cleanup_process(proc, completion)
132133
raise GitCommandError("timeout", args[0] if args else "command") from exc
133134
stdout_bytes, stdout_truncated = stdout_result
134135
stderr_bytes, stderr_truncated = stderr_result

src/pythinker_code/subagents/review_target.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424

2525
REVIEWER_AGENT_TYPES = frozenset({"review", "code-reviewer", "security-reviewer"})
2626
MAX_REVIEW_REF_CHARS = 1024
27+
_FULL_OID_LENGTHS = frozenset({40, 64})
2728

2829

2930
class ReviewTarget(BaseModel):
30-
model_config = ConfigDict(frozen=True)
31+
model_config = ConfigDict(
32+
frozen=True,
33+
extra="allow",
34+
json_schema_extra={"additionalProperties": False},
35+
)
3136

3237
kind: ReviewTargetKind = Field(
3338
default="auto",
@@ -96,6 +101,12 @@ def __init__(self, code: ReviewTargetErrorCode, brief: str, message: str) -> Non
96101

97102

98103
def validate_review_target(target: ReviewTarget) -> None:
104+
if target.model_extra:
105+
raise ReviewTargetResolutionError(
106+
ReviewTargetErrorCode.invalid_target,
107+
"Invalid review target",
108+
"review_target contains unsupported fields.",
109+
)
99110
ref = target.ref
100111
if target.kind == "commit" and ref is None:
101112
raise ReviewTargetResolutionError(
@@ -158,6 +169,7 @@ def _require_oid(result: GitCommandResult, *, message: str) -> str:
158169
or not value
159170
or "\n" in value
160171
or "\r" in value
172+
or len(value) not in _FULL_OID_LENGTHS
161173
or any(char not in string.hexdigits for char in value)
162174
):
163175
raise ReviewTargetResolutionError(
@@ -168,26 +180,48 @@ def _require_oid(result: GitCommandResult, *, message: str) -> str:
168180
return value.lower()
169181

170182

183+
def _quiet_verification_is_missing(result: GitCommandResult) -> bool:
184+
return (
185+
result.returncode == 1
186+
and not result.stdout
187+
and not result.stderr
188+
and not result.stdout_truncated
189+
and not result.stderr_truncated
190+
)
191+
192+
193+
def _raise_commit_verification_failed() -> None:
194+
raise ReviewTargetResolutionError(
195+
ReviewTargetErrorCode.git_failed,
196+
"Review target unavailable",
197+
"Git could not verify the requested commit.",
198+
)
199+
200+
171201
async def _try_resolve_commit(cwd: str, ref: str) -> str | None:
172202
result = await _run_resolver_git(
173-
["rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"],
203+
["rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}"],
174204
cwd,
175205
)
176-
if result.returncode != 0:
206+
if _quiet_verification_is_missing(result):
177207
return None
208+
if result.returncode != 0:
209+
_raise_commit_verification_failed()
178210
return _require_oid(result, message="Git returned an invalid commit identifier.")
179211

180212

181213
async def _resolve_commit(cwd: str, ref: str) -> str:
182214
result = await _run_resolver_git(
183-
["rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"], cwd
215+
["rev-parse", "--verify", "--quiet", "--end-of-options", f"{ref}^{{commit}}"], cwd
184216
)
185-
if result.returncode != 0:
217+
if _quiet_verification_is_missing(result):
186218
raise ReviewTargetResolutionError(
187219
ReviewTargetErrorCode.missing_ref,
188220
"Review target unavailable",
189221
"The requested Git ref does not resolve to a commit.",
190222
)
223+
if result.returncode != 0:
224+
_raise_commit_verification_failed()
191225
return _require_oid(result, message="Git returned an invalid commit identifier.")
192226

193227

@@ -293,14 +327,19 @@ async def _commit_details(cwd: str, target_sha: str) -> tuple[tuple[str, ...], s
293327
if parents_text.endswith("\r"):
294328
parents_text = parents_text[:-1]
295329
tokens = parents_text.split()
330+
oid_length = len(target_sha)
296331
if (
297332
parents_result.returncode != 0
298333
or parents_result.stdout_truncated
299334
or not tokens
300335
or "\n" in parents_text
301336
or "\r" in parents_text
302-
or tokens[0].lower() != target_sha
303-
or any(any(char not in string.hexdigits for char in token) for token in tokens)
337+
or oid_length not in _FULL_OID_LENGTHS
338+
or tokens[0].lower() != target_sha.lower()
339+
or any(
340+
len(token) != oid_length or any(char not in string.hexdigits for char in token)
341+
for token in tokens
342+
)
304343
):
305344
raise ReviewTargetResolutionError(
306345
ReviewTargetErrorCode.git_failed,

src/pythinker_code/tools/agent/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,8 @@ async def __call__(self, params: Params) -> ToolReturnValue:
557557
# Malformed resume id (store.instance_dir validates [A-Za-z0-9_-]{1,64}).
558558
logger.warning("Foreground agent resume id was malformed: {err}", err=exc)
559559
return ToolError(message=str(exc), brief="Agent not found")
560+
except ReviewTargetResolutionError as exc:
561+
return ToolError(message=str(exc), brief=exc.brief)
560562
except RuntimeError as exc:
561563
if "cannot be resumed concurrently" in str(exc):
562564
logger.warning("Foreground agent resume rejected: {err}", err=exc)

tests/background/test_manager.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
from pythinker_code.soul.context import Context
2424
from pythinker_code.subagents import AgentLaunchSpec, AgentTypeDefinition, ToolPolicy
2525
from pythinker_code.subagents.core import SubagentRunSpec
26-
from pythinker_code.subagents.review_target import ResolvedReviewTarget, WorktreeChanges
26+
from pythinker_code.subagents.review_target import (
27+
ResolvedReviewTarget,
28+
ReviewTargetErrorCode,
29+
ReviewTargetResolutionError,
30+
WorktreeChanges,
31+
)
2732
from pythinker_code.wire.types import TextPart
2833

2934

@@ -464,6 +469,74 @@ async def capture_prepare_soul(spec, runtime, builder, store, on_stage=None):
464469
assert captured[0].resolved_review_target == target
465470

466471

472+
@pytest.mark.asyncio
473+
async def test_background_review_target_drift_records_safe_failed_state(
474+
runtime,
475+
monkeypatch,
476+
) -> None:
477+
runtime.labor_market.add_builtin_type(
478+
AgentTypeDefinition(
479+
name="code-reviewer",
480+
description="Test code reviewer.",
481+
agent_file=runtime.subagent_store.root / "code-reviewer.yaml",
482+
tool_policy=ToolPolicy(mode="inherit"),
483+
)
484+
)
485+
runtime.subagent_store.create_instance(
486+
agent_id="adriftbg",
487+
description="review drift",
488+
launch_spec=AgentLaunchSpec(
489+
agent_id="adriftbg",
490+
subagent_type="code-reviewer",
491+
model_override=None,
492+
effective_model=None,
493+
),
494+
)
495+
target = ResolvedReviewTarget(
496+
requested_kind="base",
497+
requested_ref="main",
498+
kind="base",
499+
head_sha="b" * 40,
500+
base_ref="main",
501+
base_sha="c" * 40,
502+
merge_base_sha="a" * 40,
503+
attempted_base_refs=("main",),
504+
worktree_changes=WorktreeChanges(staged=False, unstaged=False, untracked=False),
505+
worktree_state="live",
506+
prompt="<review-target>runtime scope</review-target>",
507+
hint="base main",
508+
)
509+
monkeypatch.setattr(
510+
"pythinker_code.background.agent_runner.prepare_soul",
511+
AsyncMock(
512+
side_effect=ReviewTargetResolutionError(
513+
ReviewTargetErrorCode.head_moved,
514+
"Review target changed",
515+
"HEAD changed before background execution.",
516+
)
517+
),
518+
)
519+
520+
view = runtime.background_tasks.create_agent_task(
521+
agent_id="adriftbg",
522+
subagent_type="code-reviewer",
523+
prompt="review current changes",
524+
description="review drift",
525+
tool_call_id="tool-review-drift",
526+
model_override=None,
527+
resolved_review_target=target,
528+
)
529+
await runtime.background_tasks._live_agent_tasks[view.spec.id]
530+
531+
failed = runtime.background_tasks.store.merged_view(view.spec.id)
532+
output = runtime.background_tasks.store.output_path(view.spec.id).read_text(encoding="utf-8")
533+
assert failed.runtime.status == "failed"
534+
assert failed.runtime.failure_reason == "HEAD changed before background execution."
535+
assert runtime.subagent_store.require_instance("adriftbg").status == "failed"
536+
assert "HEAD changed before background execution." in output
537+
assert "[summary]" not in output
538+
539+
467540
@pytest.mark.asyncio
468541
async def test_background_agent_resume_restores_system_prompt_from_context(runtime, monkeypatch):
469542
runtime.labor_market.add_builtin_type(

tests/core/test_default_agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime):
488488
"review_target": {
489489
"anyOf": [
490490
{
491+
"additionalProperties": False,
491492
"properties": {
492493
"kind": {
493494
"default": "auto",

0 commit comments

Comments
 (0)