Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f23afb4
Add verified fix preparation engine
yoni-at-strix Sep 25, 2026
49eca20
Harden fix preparation against review findings
yoni-at-strix Sep 25, 2026
2b413da
Keep prepared candidates and staleness consistent across revisions
yoni-at-strix Sep 25, 2026
be1c2e4
Withhold automatic fixes when repairs exceed the recorded draft
yoni-at-strix Sep 25, 2026
9d525ad
Add bounded fix verification feedback loop
yoni-at-strix Sep 25, 2026
d02b74c
Preserve explicit blocked repair outcomes
yoni-at-strix Sep 25, 2026
a41000d
Retry unchanged repairs when verification changes
yoni-at-strix Sep 25, 2026
c510f58
fix: retry transient verifier inconclusive results
yoni-at-strix Sep 28, 2026
df313c1
refactor fix preparation gates
yoni-at-strix Sep 28, 2026
00fcb4c
fix: classify distinct check failures correctly
yoni-at-strix Sep 28, 2026
f213a7d
Require functional fix evidence and preserve partial preparation work
Sep 29, 2026
198a254
Make preparation history factory explicit for strict type checking
Sep 29, 2026
791ef91
fix: let independent evidence resolve repair timeout
yoni-at-strix Sep 29, 2026
85b3030
Preserve partial fixes and require consistent execution evidence
Sep 29, 2026
f03fd72
Simplify fix preparation around native tests and independent review
Sep 29, 2026
0faa7b7
Make fix handoffs actionable and require customer unit tests
Sep 29, 2026
43391eb
Let repair and review agents own the fix workflow
Sep 29, 2026
d184142
Let fix reviewer own validation and completion
Sep 29, 2026
348fbf2
Support native fix-agent assignments and final reviewed patches
Sep 30, 2026
5badb2d
Remove retired fix execution and proof machinery
Sep 30, 2026
d35197b
Move complete fix workflow into OSS and add strix fix CLI
Sep 30, 2026
acf262f
Keep fix outputs private and outside the source checkout
Sep 30, 2026
1c1a899
Focus fix agents and preserve completion evidence
Sep 30, 2026
77bd5da
Require an explicit fix handoff for source-backed findings
yoni-at-strix Sep 30, 2026
868ba53
Scope fix validation and warn on repeated commands
yoni-at-strix Sep 30, 2026
e4f1fe6
Merge remote-tracking branch 'origin/main' into devin/1790308365-veri…
yoni-at-strix Sep 30, 2026
ba6bbaf
Include repair follow-ups in the readable review
yoni-at-strix Sep 30, 2026
1789400
Keep an approved fix when only the PR text changes
yoni-at-strix Sep 30, 2026
8317665
Run confirmed finding fixes as native agents in the scan sandbox
yoni-at-strix Sep 30, 2026
b71ed13
Use native child delegation for finding fixes and strengthen completi…
yoni-at-strix Sep 30, 2026
1f8295c
Launch native fixes after persistence and bound completion failures
yoni-at-strix Sep 30, 2026
f75fb5f
fix: harden fix dispatch and verification (STR-815)
yoni-at-strix Oct 1, 2026
bba4aa2
fix: require reviewed current patches and enforce fix network isolation
Oct 1, 2026
60d4ce1
feat: allow scans to skip automatic fixes
yoni-at-strix Oct 1, 2026
3763a67
fix: finalize cancelled fix agents
yoni-at-strix Oct 1, 2026
1fa7d21
feat: publish verified fixes as local branches
yoni-at-strix Oct 1, 2026
f144685
fix: complete interactive autofix scans before cleanup
yoni-at-strix Oct 1, 2026
098fa36
fix: guard resumed assessments and ignore withdrawn fix records
yoni-at-strix Oct 1, 2026
9edb2ae
fix: disable automatic fix agents for PR review scans
yoni-at-strix Oct 1, 2026
8bd7f4c
refactor: use one auto-fix setting and infer fix delivery
yoni-at-strix Oct 2, 2026
818d583
Accept stray characters in validation status and hide auto-fix guidan…
yoni-at-strix Oct 2, 2026
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
Prev Previous commit
Next Next commit
fix: finalize cancelled fix agents
  • Loading branch information
yoni-at-strix committed Oct 1, 2026
commit 3763a677f94a05ff3b7fcaf1081e6d55aafdb7c1
50 changes: 42 additions & 8 deletions strix/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)

from strix.config import codex
from strix.core.agents import TERMINAL_STATUSES, Status
from strix.core.hooks import (
BudgetExceededError,
BudgetPausedError,
Expand All @@ -47,7 +48,7 @@
from agents.memory import Session, SQLiteSession
from agents.result import RunResultBase

from strix.core.agents import AgentCoordinator, Status
from strix.core.agents import AgentCoordinator


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1104,6 +1105,8 @@ async def _child_loop() -> None:
# spurious "Task exception was never retrieved" warning. The root agent
# hits the same limit on its next call and tears the scan down.
result = None
terminal_status: Status = "completed"
terminal_error = None
try:
result = await run_agent_loop(
agent=child_agent,
Expand All @@ -1119,18 +1122,49 @@ async def _child_loop() -> None:
event_sink=event_sink,
hooks=hooks,
)
except asyncio.CancelledError:
terminal_status = "stopped"
raise
except BudgetExceededError:
terminal_status = "stopped"
logger.info("child %s stopped after reaching the scan budget limit", child_id)
except SubagentBudgetReservedError:
terminal_status = "stopped"
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
except Exception as error:
terminal_status = "crashed"
terminal_error = request_log.failure_text(error)
raise
finally:
if on_complete is not None:
try:
await on_complete(result, session)
except Exception:
logger.exception("child %s completion delivery failed", child_id)
if not coordinator.is_shutting_down:
await _notify_parent_on_exit(coordinator, child_id)
try:
if on_complete is not None:
try:
await on_complete(result, session)
except asyncio.CancelledError:
terminal_status = "stopped"
raise
except Exception:
logger.exception("child %s completion delivery failed", child_id)
finally:
await _settle_child_exit(
coordinator,
child_id,
terminal_status,
terminal_error,
)

task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
await coordinator.attach_runtime(child_id, task=task_handle)


async def _settle_child_exit(
coordinator: AgentCoordinator,
child_id: str,
terminal_status: Status,
terminal_error: str | None,
) -> None:
status = await _agent_status(coordinator, child_id)
if status not in TERMINAL_STATUSES:
await coordinator.set_status(child_id, terminal_status, error=terminal_error)
if not coordinator.is_shutting_down:
await _notify_parent_on_exit(coordinator, child_id)
24 changes: 14 additions & 10 deletions strix/fix/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,7 @@ async def _dispatch(self, finding_id: str) -> None:
parent_history=[],
)
except Exception as error: # noqa: BLE001 - report launch failure to the scan
active = self.tasks.get(finding_id)
if active and not active.done():
active.cancel()
await asyncio.gather(active, return_exceptions=True)
await self._cancel_active(finding_id)
logger.warning("fix.dispatch finding=%s rejected=%s", finding_id, error)
await self.coordinator.send(
self._parent_ctx["agent_id"],
Expand Down Expand Up @@ -235,8 +232,7 @@ async def _spawn(self, finding_id: str, spawn: Any, **kwargs: Any) -> dict[str,
previous.get("reason") or "The previous Fix attempt failed; it was not restarted."
)
if running and not running.done():
running.cancel()
await asyncio.gather(running, return_exceptions=True)
await self._cancel_active(finding_id)
used = int(previous.get("turns", 0))
if used >= 300:
raise ValueError("This finding has exhausted its 300-turn Fix allowance.")
Expand Down Expand Up @@ -421,10 +417,18 @@ async def close(self) -> None:
for task in self.dispatches:
task.cancel()
await asyncio.gather(*self.dispatches, return_exceptions=True)
for task in self.tasks.values():
if not task.done():
task.cancel()
await asyncio.gather(*self.tasks.values(), return_exceptions=True)
for finding_id in list(self.tasks):
await self._cancel_active(finding_id)

async def _cancel_active(self, finding_id: str) -> None:
task = self.tasks.get(finding_id)
if task is None or task.done():
return
agent_id = self.records.get(finding_id, {}).get("agent_id")
if agent_id:
await self.coordinator.set_status(agent_id, "stopped")
task.cancel()
await asyncio.gather(task, return_exceptions=True)

async def _stage_base(self, source: Path) -> str:
key = hashlib.sha256(str(source).encode()).hexdigest()[:16]
Expand Down
87 changes: 86 additions & 1 deletion tests/test_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
import asyncio
import contextlib
import json
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import MagicMock

import pytest
from agents import RunConfig
from agents.exceptions import MaxTurnsExceeded
from agents.items import MessageOutputItem
from agents.memory import SQLiteSession
Expand All @@ -19,13 +20,18 @@
from strix.core.agents import AgentCoordinator
from strix.core.execution import (
_notify_root_on_budget_reserve,
_start_child_runner,
notify_parent_on_terminal,
)
from strix.core.sessions import seed_initial_input
from strix.tools.agents_graph.tools import agent_finish, stop_agent
from strix.tools.finish.tool import finish_scan


if TYPE_CHECKING:
from pathlib import Path


_NO_STREAM_EVENTS: list[Any] = []


Expand Down Expand Up @@ -143,6 +149,85 @@ async def test_concurrent_reserve_claims_yield_single_root() -> None:
assert all(r is None for r in results if r != "root")


@pytest.mark.asyncio
async def test_cancelled_native_child_is_always_terminal(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "fix", parent_id="root")
started = asyncio.Event()

async def run_forever(**_kwargs: Any) -> None:
started.set()
await asyncio.Event().wait()

monkeypatch.setattr(execution, "run_agent_loop", run_forever)
sessions: list[SQLiteSession] = []
await _start_child_runner(
parent_ctx={"agent_id": "root", "parent_id": None},
coordinator=coordinator,
agents_db_path=tmp_path / "agents.sqlite",
sessions_to_close=sessions,
run_config=RunConfig(tracing_disabled=True),
max_turns=10,
interactive=False,
child_agent=MagicMock(),
child_id="child",
name="fix",
parent_id="root",
task="repair",
initial_input=[],
)
await started.wait()
task = coordinator.runtimes["child"].task
assert task is not None
task.cancel()
await asyncio.gather(task, return_exceptions=True)

assert coordinator.statuses["child"] == "stopped"
for session in sessions:
session.close()


@pytest.mark.asyncio
async def test_crashed_native_child_is_always_terminal(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "fix", parent_id="root")

async def crash(**_kwargs: Any) -> None:
raise RuntimeError("repair crashed")

monkeypatch.setattr(execution, "run_agent_loop", crash)
sessions: list[SQLiteSession] = []
await _start_child_runner(
parent_ctx={"agent_id": "root", "parent_id": None},
coordinator=coordinator,
agents_db_path=tmp_path / "agents.sqlite",
sessions_to_close=sessions,
run_config=RunConfig(tracing_disabled=True),
max_turns=10,
interactive=False,
child_agent=MagicMock(),
child_id="child",
name="fix",
parent_id="root",
task="repair",
initial_input=[],
)
task = coordinator.runtimes["child"].task
assert task is not None
await asyncio.gather(task, return_exceptions=True)

assert coordinator.statuses["child"] == "crashed"
assert coordinator.errors["child"] == "repair crashed"
for session in sessions:
session.close()


@pytest.mark.asyncio
async def test_claim_reserve_sets_flag_and_wakes_parked_agents() -> None:
coordinator = AgentCoordinator()
Expand Down
28 changes: 24 additions & 4 deletions tests/test_scan_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import asyncio
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, Mock

import pytest
Expand All @@ -17,7 +18,11 @@
from strix.core.agents import AgentCoordinator
from strix.core.execution import spawn_child_agent
from strix.core.hooks import ReportUsageHooks
from strix.fix import FindingContext
from strix.fix import (
FindingContext,
FixPreparationResultV1,
PreparationState,
)
from strix.fix import scan as scan_module
from strix.fix.scan import ScanFixes
from strix.fix.session import WorktreeSession
Expand Down Expand Up @@ -188,12 +193,27 @@ async def test_finding_revision_invalidates_active_completion(tmp_path):
assert not fixes._current("finding", digest)


@pytest.mark.asyncio
async def test_superseded_fix_agent_is_marked_stopped_before_cancellation(tmp_path):
fixes, _, _, _, _, _, _ = setup(tmp_path)
await fixes.coordinator.register("old-fix", "Fix", "reporter", skills=["fix_task"])
await fixes.coordinator.mark_running("old-fix")
task = asyncio.create_task(asyncio.Event().wait())
fixes.records["finding"] = {"agent_id": "old-fix", "status": "running"}
fixes.tasks["finding"] = task

await fixes._cancel_active("finding")

assert fixes.coordinator.statuses["old-fix"] == "stopped"
assert task.cancelled()


@pytest.mark.parametrize("change", ["revised", "withdrawn", "unconfirmed"])
async def test_finding_changed_before_delivery_discards_reviewed_patch(
tmp_path, monkeypatch, change
):
fixes, report, _, _, reports, context, _ = setup(tmp_path)
callback = None
callback: Any = None

async def spawn(**kwargs):
nonlocal callback
Expand All @@ -211,8 +231,8 @@ async def finish_preparation(request, _env, _hooks, _result, _session, artifact)
reports.clear()
else:
report["validation_status"] = "unconfirmed"
return scan_module.FixPreparationResultV1(
state="ready",
return FixPreparationResultV1(
state=PreparationState.READY,
stop_reason="Approved.",
source_identity=request.candidate.source_identity,
candidate=request.candidate,
Expand Down