Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ JSON output paths must restore the console's configured backing stream, not a
dynamic temporary stdout capture.
Run-command diagnostics before headless execution belong on stderr so JSON
stdout remains one parseable payload.
Both chat and headless entry points use shared pre-turn transcript repair and
persist before dispatch. A failed repair checkpoint must prevent execution;
a successful one must not suppress saving a subsequent failed turn.
Test this through actual `execute_single`, not only extracted interactive helpers.

## Fork event ownership and cumulative cost

Expand Down
65 changes: 13 additions & 52 deletions amplifier_app_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@
from .provider_diagnostics import format_model_line
from .provider_diagnostics import invoke_list_models
from .provider_diagnostics import test_provider_connectivity
from .session_runner import SessionConfig, create_initialized_session
from .session_runner import (
SessionConfig,
create_initialized_session,
repair_transcript_if_needed,
)
from .session_store import SessionStore
from .stdout_offload import patch_stdout_offloaded as patch_stdout
from .ui.dashboard_renderer import DashboardRenderer
Expand Down Expand Up @@ -4002,57 +4006,8 @@ async def _save_session():

# Helper to detect and repair broken transcripts before each turn
async def _repair_transcript_if_needed():
"""Pre-turn transcript repair.

Detects and fixes orphaned tool calls, ordering violations, and
incomplete assistant turns left by interrupted operations (Ctrl+C,
SIGKILL, OOM, MCP transport failures).

Uses the same foundation diagnosis library as resume-time repair
(session_runner.py), but operates on live in-memory context messages
rather than on-disk transcript files. Runs once per turn; the scan
is a pure in-memory walk (<10 ms for typical sessions).
"""
context = session.coordinator.get("context")
if not context or not hasattr(context, "get_messages"):
return

try:
messages = await context.get_messages()
if not messages:
return

from amplifier_foundation.session import (
diagnose_transcript,
repair_transcript,
)

diagnosis = diagnose_transcript(messages)
if diagnosis["status"] != "broken":
return

failure_modes = diagnosis.get("failure_modes", [])
orphan_ids = diagnosis.get("orphaned_tool_ids", [])

# Repair and update context in-place
repaired = repair_transcript(messages, diagnosis)
if hasattr(context, "set_messages"):
await context.set_messages(repaired)

# Persist immediately so the fix survives further interruptions
await _save_session()

logger.warning(
"Pre-turn transcript repair: %s (orphaned tool calls: %s).",
", ".join(failure_modes),
", ".join(orphan_ids) if orphan_ids else "none",
)
except ImportError:
# Foundation not available (non-standard setup) — skip repair
pass
except Exception as e:
# Repair must never block the session — log and continue
logger.debug("Pre-turn transcript repair failed: %s", e)
"""Repair live history and checkpoint through the interactive save path."""
await repair_transcript_if_needed(session, _save_session)

# Helper to execute a prompt with Ctrl+C handling
async def _execute_with_interrupt(prompt_text: str) -> bool:
Expand Down Expand Up @@ -4854,6 +4809,12 @@ def _goal_sigint_handler(signum, frame):

_original_sigint_handler = signal.signal(signal.SIGINT, _goal_sigint_handler)
try:
await repair_transcript_if_needed(session, _persist_session)
if handoff.requested.is_set():
return
# A successful repair checkpoint must not suppress saving messages
# added by a subsequent failed turn. Failed repair saves never get here.
session_save_attempted = False
execution_started = True
response = await session.execute(prompt)
finally:
Expand Down
56 changes: 53 additions & 3 deletions amplifier_app_cli/session_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import sys
import uuid
from collections import Counter
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
Expand All @@ -51,6 +52,55 @@
logger = logging.getLogger(__name__)


async def repair_transcript_if_needed(
session: AmplifierSession, save_session: Callable[[], Awaitable[Any]]
) -> None:
"""Apply Foundation's best-effort repair, then persist before dispatch.

Unsafe associations may remain broken; Foundation owns that decision.
A failed checkpoint must propagate rather than dispatch an unsaved repair.
"""
try:
context = session.coordinator.get("context")
if (
not context
or not hasattr(context, "get_messages")
or not hasattr(context, "set_messages")
):
return

messages = await context.get_messages()
if not messages:
return

from amplifier_foundation.session import (
diagnose_transcript,
repair_transcript,
)

diagnosis = diagnose_transcript(messages)
if diagnosis["status"] != "broken":
return

failure_modes = diagnosis.get("failure_modes", [])
orphan_ids = diagnosis.get("orphaned_tool_ids", [])
repaired = repair_transcript(messages, diagnosis)
await context.set_messages(repaired)
except Exception as exc:
# Foundation availability, diagnosis and context mutation are best-effort.
logger.debug("Pre-turn transcript repair failed: %s", exc)
return

# Deliberately outside the best-effort boundary, including ImportError and
# cancellation: never dispatch a repair whose persistence failed.
await save_session()
logger.warning(
"Pre-turn transcript repair: %s (orphaned tool calls: %s).",
", ".join(failure_modes),
", ".join(orphan_ids) if orphan_ids else "none",
)


@dataclass
class SessionConfig:
"""All parameters needed to create and initialize a session.
Expand Down Expand Up @@ -299,9 +349,9 @@ async def create_initialized_session(
)

# Step 7: Restore transcript (resume only)
# NOTE: Transcript repair (orphaned tool calls, ordering violations) is handled
# by _repair_transcript_if_needed() in main.py, which runs before every LLM call.
# This covers both resume and mid-session Ctrl+C cases in a single code path.
# Initialization only restores history. Chat and headless host entry points
# call shared pre-turn repair and persist it before execution, once their
# save callbacks exist (including after mid-session interruptions).
if config.is_resume and config.initial_transcript:
transcript_to_restore = config.initial_transcript

Expand Down
Loading
Loading