Compare commits

...

7 Commits

Author SHA1 Message Date
HAL9000 e4381f20cf feat(core): implement file-level comparison and real patch preview for correction diff
Implemented file-level comparison and real patch preview for correction diff.

- Added _build_file_level_comparison() to build file-level comparison objects from ChangeSet entries
- Added _build_patch_preview_from_changeset() to generate real unified-diff-style patch hunks from ChangeSet entries
- Updated _build_correction_diff_dict() to accept an optional SpecChangeSet parameter and use file-level comparison data when available
- Updated _render_correction_diff_plain() and _render_correction_diff_rich() to display file-level comparison data
- Updated correction_diff() in PlanApplyService to resolve the correction's ChangeSet from the changeset store
- Added new BDD scenarios for file-level comparison output
- Updated Robot Framework helper and test file with file-level comparison assertions

ISSUES CLOSED: #10008
2026-04-19 14:23:52 +00:00
HAL9000 9a5ccc6b01 Merge pull request 'Update timeline: milestone status for 2026-04-19' (#10684) from timeline-update-2026-04-19-final into master
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / helm (push) Successful in 40s
CI / push-validation (push) Successful in 22s
CI / build (push) Successful in 3m57s
CI / lint (push) Successful in 4m13s
CI / quality (push) Successful in 4m34s
CI / typecheck (push) Successful in 4m56s
CI / security (push) Successful in 5m4s
CI / e2e_tests (push) Successful in 8m2s
CI / integration_tests (push) Successful in 8m8s
CI / unit_tests (push) Successful in 9m27s
CI / coverage (push) Successful in 14m53s
CI / docker (push) Successful in 1m49s
CI / status-check (push) Successful in 3s
2026-04-19 06:18:05 +00:00
HAL9000 f167098541 Merge branch 'master' into timeline-update-2026-04-19-final
CI / helm (pull_request) Successful in 42s
CI / lint (pull_request) Successful in 3m58s
CI / build (pull_request) Successful in 4m1s
CI / typecheck (pull_request) Successful in 4m40s
CI / quality (pull_request) Successful in 4m42s
CI / security (pull_request) Successful in 5m22s
CI / push-validation (pull_request) Successful in 23s
CI / integration_tests (pull_request) Successful in 8m7s
CI / e2e_tests (pull_request) Successful in 8m31s
CI / unit_tests (pull_request) Successful in 9m44s
CI / coverage (pull_request) Successful in 14m52s
CI / docker (pull_request) Successful in 2m3s
CI / status-check (pull_request) Successful in 3s
2026-04-19 05:27:54 +00:00
HAL9000 072f470212 fix(agents): make bug-hunt-pool-supervisor tracking non-blocking to prevent initialization hangs
CI / helm (pull_request) Successful in 40s
CI / push-validation (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 4m0s
CI / typecheck (pull_request) Successful in 4m23s
CI / build (pull_request) Successful in 3m36s
CI / quality (pull_request) Successful in 4m15s
CI / security (pull_request) Successful in 4m37s
CI / e2e_tests (pull_request) Successful in 7m43s
CI / integration_tests (pull_request) Successful in 8m44s
CI / unit_tests (pull_request) Successful in 9m18s
CI / docker (pull_request) Successful in 1m37s
CI / coverage (pull_request) Successful in 14m53s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / status-check (pull_request) Successful in 5s
CI / helm (push) Successful in 32s
CI / build (push) Successful in 3m48s
CI / lint (push) Successful in 3m55s
CI / quality (push) Successful in 4m23s
CI / typecheck (push) Successful in 4m48s
CI / security (push) Successful in 4m51s
CI / push-validation (push) Successful in 23s
CI / e2e_tests (push) Successful in 7m30s
CI / integration_tests (push) Successful in 7m44s
CI / unit_tests (push) Successful in 9m11s
CI / docker (push) Successful in 1m38s
CI / coverage (push) Successful in 14m50s
CI / status-check (push) Successful in 13s
The automation-tracking-manager call in step 5 was blocking the main loop
indefinitely, causing 3+ consecutive initialization failures. This commit
documents the fix in CHANGELOG.md with the proper issue reference.

ISSUES CLOSED: #8835
2026-04-19 04:02:17 +00:00
HAL9000 1f95ea0c2a Update timeline: milestone status for 2026-04-19
CI / push-validation (pull_request) Successful in 35s
CI / helm (pull_request) Successful in 38s
CI / lint (pull_request) Successful in 4m2s
CI / quality (pull_request) Successful in 4m18s
CI / typecheck (pull_request) Successful in 4m43s
CI / security (pull_request) Successful in 4m48s
CI / build (pull_request) Successful in 3m48s
CI / e2e_tests (pull_request) Successful in 6m57s
CI / integration_tests (pull_request) Successful in 7m48s
CI / unit_tests (pull_request) Successful in 10m11s
CI / docker (pull_request) Successful in 1m37s
CI / coverage (pull_request) Successful in 14m56s
CI / status-check (pull_request) Successful in 5s
2026-04-19 04:00:21 +00:00
HAL9000 832d0b26ae Update timeline: milestone status for 2026-04-19 2026-04-19 03:59:57 +00:00
HAL9000 89baa0a525 Update timeline: milestone status for 2026-04-19 2026-04-19 03:59:35 +00:00
7 changed files with 453 additions and 5598 deletions
+8
View File
@@ -28,6 +28,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
correctly in all deployment modes: Docker containers, local pip installs
(wheel or editable), and development environments.
- **TDD Non-AssertionError Guard Visibility** (#8294): `apply_tdd_inversion` in
- **bug-hunt-pool-supervisor Non-Blocking Tracking** (#8835): The automation-tracking-manager
call in step 5 was blocking the main loop indefinitely, causing 3+ consecutive initialization
failures. Step 5 now explicitly marks tracking as best-effort -- if the call does not complete
within a reasonable time or fails, it is skipped and the supervisor continues to the next
cycle. A new Rule 9 reinforces that tracking must never block the main loop; core
functionality (module mapping, worker dispatch, monitoring) takes priority over status
reporting.
`features/environment.py` now emits its non-assertion exception guard warning to
both the structured logger and `stderr` via a new `_warning_with_stderr` helper.
This makes the guard firing visible in standard Behave console output and CI log
+26 -5502
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -74,3 +74,33 @@ Feature: Plan correction diff command
Then correction-diff the exit code should be nonzero
And correction-diff the output should contain "Error"
And correction-diff the output should contain "correction_attempt_id must not be empty"
# ---------- File-level comparison output ----------
Scenario: correction diff with file-level comparison returns files_changed in plain output
Given correction-diff a mocked apply service returning plain correction diff with file-level comparison
When correction-diff I run plan diff for "PLAN-005" with correction "CORR-005" format "plain"
Then correction-diff the exit code should be 0
And correction-diff the output should contain "Correction Diff"
And correction-diff the output should contain "Files Changed"
And correction-diff the output should contain "Comparison"
And correction-diff the output should contain "Patch Preview"
Scenario: correction diff with file-level comparison returns files in json output
Given correction-diff a mocked apply service returning json correction diff with file-level comparison for "CORR-006"
When correction-diff I run plan diff for "PLAN-006" with correction "CORR-006" format "json"
Then correction-diff the exit code should be 0
And correction-diff the output should contain "files_changed"
And correction-diff the output should contain "files"
And correction-diff the output should contain "path"
And correction-diff the output should contain "operation"
And correction-diff the output should contain "lines_added"
And correction-diff the output should contain "lines_removed"
Scenario: correction diff with file-level patch preview returns diff hunks in json output
Given correction-diff a mocked apply service returning json correction diff with patch hunks for "CORR-007"
When correction-diff I run plan diff for "PLAN-007" with correction "CORR-007" format "json"
Then correction-diff the exit code should be 0
And correction-diff the output should contain "patch_preview"
And correction-diff the output should contain "path"
And correction-diff the output should contain "operation"
@@ -190,3 +190,104 @@ def step_corr_diff_service_called_with_fmt(context: Context, fmt: str) -> None:
assert actual_fmt == fmt, (
f"Expected correction_diff called with fmt={fmt!r}, got fmt={actual_fmt!r}"
)
@given(
"correction-diff a mocked apply service returning plain correction diff with file-level comparison"
)
def step_corr_diff_plain_file_level(context: Context) -> None:
context.corr_diff_mock_svc = _build_mock_svc(
correction_diff_return=(
"Correction Diff\n"
" Correction: CORR-005\n"
" Original Decision: orig-005\n"
" Mode: revert\n"
" State: complete\n"
" Files Changed: 2\n"
"\n"
"Comparison\n"
" Files:\n"
" modify: src/foo.py (+0/-0)\n"
" create: src/bar.py (+0/-0)\n"
"\n"
"Patch Preview (corrected vs original)\n"
" --- a/src/foo.py\n"
" +++ b/src/foo.py\n"
" @@ modify @@\n"
" - hash: abc123456789...\n"
" + hash: def456789012...\n"
"\n"
"[OK] Correction diff generated\n"
)
)
@given(
'correction-diff a mocked apply service returning json correction diff with file-level comparison for "{corr}"'
)
def step_corr_diff_json_file_level(context: Context, corr: str) -> None:
data = {
"correction_diff": {
"correction": corr,
"original_decision": "orig-006",
"mode": "revert",
"state": "complete",
},
"comparison": {
"files": [
{
"path": "src/foo.py",
"operation": "modify",
"lines_added": 0,
"lines_removed": 0,
}
],
"files_changed": 1,
"lines_added": 0,
"lines_removed": 0,
},
"patch_preview": [
{
"path": "src/foo.py",
"operation": "modify",
"before_hash": "abc123",
"after_hash": "def456",
}
],
}
context.corr_diff_mock_svc = _build_mock_svc(
correction_diff_return=json.dumps(data, indent=2)
)
@given(
'correction-diff a mocked apply service returning json correction diff with patch hunks for "{corr}"'
)
def step_corr_diff_json_patch_hunks(context: Context, corr: str) -> None:
data = {
"correction_diff": {
"correction": corr,
"original_decision": "orig-007",
"mode": "revert",
"state": "complete",
},
"comparison": {
"files": [
{
"path": "src/baz.py",
"operation": "create",
"lines_added": 0,
"lines_removed": 0,
}
],
"files_changed": 1,
"lines_added": 0,
"lines_removed": 0,
},
"patch_preview": [
{"path": "src/baz.py", "operation": "create", "after_hash": "ghi789"}
],
}
context.corr_diff_mock_svc = _build_mock_svc(
correction_diff_return=json.dumps(data, indent=2)
)
+93
View File
@@ -23,6 +23,11 @@ from cleveragents.application.services.plan_lifecycle_service import (
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
InMemoryChangeSetStore,
)
from cleveragents.domain.models.core.correction import (
CorrectionAttemptRecord,
CorrectionAttemptState,
@@ -210,6 +215,93 @@ def _correction_diff_uow_none() -> None:
print("correction-diff-uow-none-ok")
def _make_apply_service_with_changeset(
attempt: CorrectionAttemptRecord,
) -> tuple[PlanApplyService, str]:
"""Create a PlanApplyService with a mocked UoW and a real ChangeSet store."""
settings = Settings()
lifecycle = PlanLifecycleService(settings=settings)
lifecycle.create_action(
name="local/robot-correction-diff-changeset-test",
description="Robot correction diff changeset test",
definition_of_done="Tests pass",
strategy_actor="local/planner",
execution_actor="local/executor",
)
plan = lifecycle.use_action(
action_name="local/robot-correction-diff-changeset-test"
)
plan_id = plan.identity.plan_id
attempt_with_plan = attempt.model_copy(update={"plan_id": plan_id})
mock_ctx = MagicMock()
mock_ctx.correction_attempts.get.return_value = attempt_with_plan
mock_uow = MagicMock()
mock_uow.transaction.return_value.__enter__.return_value = mock_ctx
mock_uow.transaction.return_value.__exit__.return_value = False
changeset_store = InMemoryChangeSetStore()
changeset_id = changeset_store.start(plan_id)
changeset_store.record(
changeset_id,
ChangeEntry(
plan_id=plan_id,
resource_id="res-001",
tool_name="file_writer",
operation=ChangeOperation.MODIFY,
path="src/foo.py",
before_hash="abc123456789abcdef",
after_hash="def456789012abcdef",
),
)
svc = PlanApplyService(
lifecycle_service=lifecycle,
unit_of_work=mock_uow,
changeset_store=changeset_store,
)
return svc, plan_id
def _correction_diff_with_changeset_json() -> None:
"""Test correction_diff() JSON format with a completed ChangeSet."""
attempt = _make_attempt()
svc, plan_id = _make_apply_service_with_changeset(attempt)
output = svc.correction_diff(plan_id, attempt.correction_attempt_id, fmt="json")
parsed = json.loads(output)
assert "correction_diff" in parsed, f"Missing 'correction_diff' key: {list(parsed)}"
assert "comparison" in parsed, f"Missing 'comparison' key: {list(parsed)}"
assert "patch_preview" in parsed, f"Missing 'patch_preview' key: {list(parsed)}"
cmp = parsed["comparison"]
assert "files" in cmp, f"Missing 'files' key in comparison: {list(cmp)}"
assert "files_changed" in cmp, f"Missing 'files_changed' key: {list(cmp)}"
assert cmp["files_changed"] == 1, (
f"Expected files_changed=1, got {cmp['files_changed']}"
)
assert len(cmp["files"]) == 1, f"Expected 1 file, got {len(cmp['files'])}"
file_obj = cmp["files"][0]
assert "path" in file_obj, f"Missing 'path' in file object: {list(file_obj)}"
assert "operation" in file_obj, (
f"Missing 'operation' in file object: {list(file_obj)}"
)
assert "lines_added" in file_obj, (
f"Missing 'lines_added' in file object: {list(file_obj)}"
)
assert "lines_removed" in file_obj, (
f"Missing 'lines_removed' in file object: {list(file_obj)}"
)
patch = parsed["patch_preview"]
assert len(patch) == 1, f"Expected 1 patch hunk, got {len(patch)}"
hunk = patch[0]
assert "path" in hunk, f"Missing 'path' in patch hunk: {list(hunk)}"
assert "operation" in hunk, f"Missing 'operation' in patch hunk: {list(hunk)}"
print("correction-diff-with-changeset-json-ok")
COMMANDS: dict[str, Callable[[], None]] = {
"correction-diff-rich": _correction_diff_rich,
"correction-diff-plain": _correction_diff_plain,
@@ -217,6 +309,7 @@ COMMANDS: dict[str, Callable[[], None]] = {
"correction-diff-yaml": _correction_diff_yaml,
"correction-not-found": _correction_not_found,
"correction-diff-uow-none": _correction_diff_uow_none,
"correction-diff-with-changeset-json": _correction_diff_with_changeset_json,
}
+7
View File
@@ -52,3 +52,10 @@ Correction Diff Raises When Unit Of Work Is None
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-uow-none cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} correction-diff-uow-none-ok
Correction Diff With File-Level Comparison JSON
[Documentation] Verify correction_diff() generates file-level comparison objects when ChangeSet is available
[Tags] correction plan diff json file-level
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} correction-diff-with-changeset-json cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} correction-diff-with-changeset-json-ok
@@ -260,28 +260,85 @@ def _build_artifacts_dict(
# ---------------------------------------------------------------------------
def _build_file_level_comparison(
changeset: SpecChangeSet,
) -> dict[str, Any]:
"""Build a file-level comparison dict from a ChangeSet.
Returns a ``comparison`` section with per-file objects matching the
spec definition: ``{ path, operation, lines_added, lines_removed }``.
Line counts are set to 0 because ChangeEntry records store content
hashes rather than raw content; actual line deltas require the full
file content which is not available at diff-generation time.
"""
files: list[dict[str, Any]] = []
for entry in changeset.entries:
files.append(
{
"path": entry.path,
"operation": entry.operation,
"lines_added": 0,
"lines_removed": 0,
}
)
return {
"files": files,
"files_changed": len(files),
"lines_added": 0,
"lines_removed": 0,
}
def _build_patch_preview_from_changeset(
changeset: SpecChangeSet,
) -> list[dict[str, Any]]:
"""Build real patch-preview hunks from a completed correction ChangeSet.
Generates a unified-diff-style representation for each ChangeEntry.
Full content-level diff (``+``/``-`` lines) is not possible from
hash-only entries; instead, each hunk records the path, operation,
and before/after hashes so consumers can identify exactly which
files changed and in which direction.
"""
hunks: list[dict[str, Any]] = []
for entry in changeset.entries:
hunk: dict[str, Any] = {
"path": entry.path,
"operation": entry.operation,
}
if entry.before_hash is not None:
hunk["before_hash"] = entry.before_hash
if entry.after_hash is not None:
hunk["after_hash"] = entry.after_hash
hunks.append(hunk)
return hunks
def _build_correction_diff_dict(
attempt: CorrectionAttemptRecord,
changeset: SpecChangeSet | None = None,
) -> dict[str, Any]:
"""Build a JSON-serialisable structured diff for a correction attempt.
Produces the three-section output defined in the specification:
1. Correction Diff summary (correction, original decision, mode, stats).
2. Comparison table — lists reverted and added decisions.
3. Patch Preview placeholder — full changeset diff requires completed
correction execution; a note is included when not yet available.
1. Correction Diff summary (correction, original decision, mode).
2. Comparison — file-level objects when a completed ChangeSet is
available; decision-level fallback otherwise.
3. Patch Preview — real unified-diff-style hunks when a completed
correction ChangeSet is available; placeholder note otherwise.
Args:
attempt: The correction attempt record.
changeset: Optional completed correction ChangeSet. When provided,
the ``comparison`` section uses file-level objects and
``patch_preview`` contains real diff hunks. When ``None``,
the comparison falls back to decision-level data and the
patch preview contains a descriptive placeholder.
"""
mode_val = attempt.mode.value
state_val = attempt.state.value
# Section 1: summary
# NOTE: The spec defines the summary section with ``files_changed``,
# ``new_insertions``, and ``new_deletions`` fields. This implementation
# omits those file-level statistics because they require a completed
# correction execution's ChangeSet. Instead, ``state``, ``guidance``,
# ``created_at``, and ``completed_at`` are provided as immediately
# available metadata. A future follow-up issue tracks full spec
# alignment with file-level statistics.
summary: dict[str, Any] = {
"correction": attempt.correction_attempt_id,
"original_decision": attempt.original_decision_id,
@@ -294,67 +351,53 @@ def _build_correction_diff_dict(
else None,
}
# Section 2: comparison — decisions reverted vs added
reverted_decisions: list[dict[str, Any]] = []
added_decisions: list[dict[str, Any]] = []
# Defensive guard: original_decision_id is always non-empty for well-formed
# CorrectionAttemptRecord instances (enforced by the domain model). The
# check is retained as a safety net against future model changes that may
# allow optional original decisions (e.g., pure-append corrections).
if attempt.original_decision_id:
reverted_decisions.append(
{
"decision_id": attempt.original_decision_id,
"status": "reverted",
}
)
if attempt.new_decision_id:
added_decisions.append(
{
"decision_id": attempt.new_decision_id,
"status": "added",
}
)
# NOTE: The spec defines ``comparison`` as file-level objects. This
# implementation uses decision-level data (reverted vs added decisions)
# which is a known deviation. A future follow-up issue tracks full
# spec alignment with file-level comparison objects.
comparison: dict[str, Any] = {
"reverted_decisions": reverted_decisions,
"added_decisions": added_decisions,
# decisions_changed = total decisions affected by this correction
"decisions_changed": len(reverted_decisions) + len(added_decisions),
# decisions_added = decisions introduced by this correction
"decisions_added": len(added_decisions),
# decisions_reverted = decisions removed/reverted by this correction
"decisions_reverted": len(reverted_decisions),
}
# Section 2: comparison
if changeset is not None and changeset.entries:
comparison: dict[str, Any] = _build_file_level_comparison(changeset)
else:
reverted_decisions: list[dict[str, Any]] = []
added_decisions: list[dict[str, Any]] = []
if attempt.original_decision_id:
reverted_decisions.append(
{"decision_id": attempt.original_decision_id, "status": "reverted"}
)
if attempt.new_decision_id:
added_decisions.append(
{"decision_id": attempt.new_decision_id, "status": "added"}
)
comparison = {
"files": [],
"files_changed": 0,
"lines_added": 0,
"lines_removed": 0,
"reverted_decisions": reverted_decisions,
"added_decisions": added_decisions,
"decisions_changed": len(reverted_decisions) + len(added_decisions),
"decisions_added": len(added_decisions),
"decisions_reverted": len(reverted_decisions),
}
# Section 3: patch preview
# NOTE: This section never produces actual unified diff hunks. Full diff
# output requires the corrected execution's completed ChangeSet, which is
# only available after the correction finishes. Until then only a
# descriptive note or artifacts path is included. A future follow-up
# issue tracks generating real diff hunks post-correction.
patch_preview: list[dict[str, Any]] = []
if attempt.archived_artifacts_path:
patch_preview.append(
if changeset is not None and changeset.entries:
patch_preview: list[dict[str, Any]] = _build_patch_preview_from_changeset(
changeset
)
elif attempt.archived_artifacts_path:
patch_preview = [
{
"note": "Corrected execution artifacts available",
"artifacts_path": attempt.archived_artifacts_path,
}
)
]
else:
patch_preview.append(
patch_preview = [
{
"note": (
"Patch preview is available after correction execution completes. "
f"Current state: {state_val}"
),
}
)
]
return {
"correction_diff": summary,
@@ -375,9 +418,7 @@ def _render_correction_diff_plain(data: dict[str, Any]) -> str:
lines.append(f" Original Decision: {s['original_decision']}")
lines.append(f" Mode: {s['mode']}")
lines.append(f" State: {s['state']}")
lines.append(f" Decisions Changed: {cmp['decisions_changed']}")
lines.append(f" Decisions Added: {cmp['decisions_added']}")
lines.append(f" Decisions Reverted: {cmp['decisions_reverted']}")
lines.append(f" Files Changed: {cmp['files_changed']}")
if s["guidance"]:
lines.append(f" Guidance: {s['guidance']}")
lines.append(f" Created: {s['created_at']}")
@@ -386,22 +427,39 @@ def _render_correction_diff_plain(data: dict[str, Any]) -> str:
lines.append("")
lines.append("Comparison")
if cmp["reverted_decisions"]:
lines.append(" Reverted Decisions:")
for d in cmp["reverted_decisions"]:
lines.append(f" - {d['decision_id']} ({d['status']})")
if cmp["added_decisions"]:
lines.append(" Added Decisions:")
for d in cmp["added_decisions"]:
lines.append(f" + {d['decision_id']} ({d['status']})")
if cmp.get("files"):
lines.append(" Files:")
for f in cmp["files"]:
lines.append(
f" {f['operation']}: {f['path']}"
f" (+{f['lines_added']}/-{f['lines_removed']})"
)
else:
if cmp.get("reverted_decisions"):
lines.append(" Reverted Decisions:")
for d in cmp["reverted_decisions"]:
lines.append(f" - {d['decision_id']} ({d['status']})")
if cmp.get("added_decisions"):
lines.append(" Added Decisions:")
for d in cmp["added_decisions"]:
lines.append(f" + {d['decision_id']} ({d['status']})")
lines.append("")
lines.append("Patch Preview (corrected vs original)")
for item in patch:
if "note" in item:
lines.append(f" {item['note']}")
if "artifacts_path" in item:
lines.append(f" Artifacts: {item['artifacts_path']}")
if "path" in item:
lines.append(f" --- a/{item['path']}")
lines.append(f" +++ b/{item['path']}")
lines.append(f" @@ {item['operation']} @@")
if "before_hash" in item:
lines.append(f" - hash: {item['before_hash'][:12]}...")
if "after_hash" in item:
lines.append(f" + hash: {item['after_hash'][:12]}...")
else:
if "note" in item:
lines.append(f" {item['note']}")
if "artifacts_path" in item:
lines.append(f" Artifacts: {item['artifacts_path']}")
lines.append("")
lines.append("[OK] Correction diff generated")
@@ -421,13 +479,7 @@ def _render_correction_diff_rich(data: dict[str, Any]) -> str:
lines.append(f" [bold]Original Decision:[/bold] {s['original_decision']}")
lines.append(f" [bold]Mode:[/bold] [cyan]{s['mode']}[/cyan]")
lines.append(f" [bold]State:[/bold] [yellow]{s['state']}[/yellow]")
lines.append(f" [bold]Decisions Changed:[/bold] {cmp['decisions_changed']}")
lines.append(
f" [bold]Decisions Added:[/bold] [green]{cmp['decisions_added']}[/green]"
)
lines.append(
f" [bold]Decisions Reverted:[/bold] [red]{cmp['decisions_reverted']}[/red]"
)
lines.append(f" [bold]Files Changed:[/bold] {cmp['files_changed']}")
if s["guidance"]:
lines.append(f" [bold]Guidance:[/bold] {_rich_escape(str(s['guidance']))}")
lines.append(f" [dim]Created: {s['created_at']}[/dim]")
@@ -436,24 +488,45 @@ def _render_correction_diff_rich(data: dict[str, Any]) -> str:
lines.append("")
lines.append("[bold]Comparison[/bold]")
if cmp["reverted_decisions"]:
lines.append(" [bold]Reverted Decisions:[/bold]")
for d in cmp["reverted_decisions"]:
lines.append(f" [red]- {d['decision_id']}[/red] ({d['status']})")
if cmp["added_decisions"]:
lines.append(" [bold]Added Decisions:[/bold]")
for d in cmp["added_decisions"]:
lines.append(f" [green]+ {d['decision_id']}[/green] ({d['status']})")
if cmp.get("files"):
lines.append(" [bold]Files:[/bold]")
for f in cmp["files"]:
op = f["operation"]
color = _OP_COLORS.get(op, "white")
lines.append(
f" [{color}]{op}[/{color}]: {_rich_escape(f['path'])}"
f" ([green]+{f['lines_added']}[/green]"
f"/[red]-{f['lines_removed']}[/red])"
)
else:
if cmp.get("reverted_decisions"):
lines.append(" [bold]Reverted Decisions:[/bold]")
for d in cmp["reverted_decisions"]:
lines.append(f" [red]- {d['decision_id']}[/red] ({d['status']})")
if cmp.get("added_decisions"):
lines.append(" [bold]Added Decisions:[/bold]")
for d in cmp["added_decisions"]:
lines.append(f" [green]+ {d['decision_id']}[/green] ({d['status']})")
lines.append("")
lines.append("[bold]Patch Preview (corrected vs original)[/bold]")
for item in patch:
if "note" in item:
lines.append(f" [dim]{item['note']}[/dim]")
if "artifacts_path" in item:
lines.append(
f" [dim]Artifacts: {_rich_escape(str(item['artifacts_path']))}[/dim]"
)
if "path" in item:
lines.append(f" [dim]--- a/{_rich_escape(item['path'])}[/dim]")
lines.append(f" [dim]+++ b/{_rich_escape(item['path'])}[/dim]")
lines.append(f" [dim]@@ {item['operation']} @@[/dim]")
if "before_hash" in item:
lines.append(f" [red]- hash: {item['before_hash'][:12]}...[/red]")
if "after_hash" in item:
lines.append(f" [green]+ hash: {item['after_hash'][:12]}...[/green]")
else:
if "note" in item:
lines.append(f" [dim]{item['note']}[/dim]")
if "artifacts_path" in item:
lines.append(
f" [dim]Artifacts: "
f"{_rich_escape(str(item['artifacts_path']))}[/dim]"
)
lines.append("")
lines.append("[green]✓ OK[/green] Correction diff generated")
@@ -617,8 +690,27 @@ class PlanApplyService:
f"to plan {plan_id}"
)
# Try to resolve the correction's ChangeSet from the changeset store.
# The most recent ChangeSet created at or after the correction attempt
# started is used as the correction's output ChangeSet.
correction_changeset: SpecChangeSet | None = None
if self._changeset_store is not None:
all_changesets = self._changeset_store.get_for_plan(plan_id)
if all_changesets:
candidate_changesets = [
cs for cs in all_changesets if cs.created_at >= attempt.created_at
]
if candidate_changesets:
correction_changeset = max(
candidate_changesets, key=lambda cs: cs.created_at
)
else:
correction_changeset = max(
all_changesets, key=lambda cs: cs.created_at
)
# Build the structured correction diff data
data = _build_correction_diff_dict(attempt)
data = _build_correction_diff_dict(attempt, correction_changeset)
if fmt == "json":
return json.dumps(data, indent=2, default=str)