Skip to content
Open
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
24 changes: 20 additions & 4 deletions docs/conversationbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,16 @@ Cancellation looks simple on the surface — set a flag, check it, stop — but
3. Controller validates ownership and checks the current status:
- If already terminal (`Completed`, `Failed`, `Cancelled`): returns `{ success: true, alreadyFinished: true }`.
- Otherwise: sets status to `Cancelling` and flushes.
4. Frontend keeps polling — it does **not** stop immediately. This ensures all chunks produced before cancellation are displayed.
5. The handler detects `Cancelling` at the next DBAL status check (see 6.2), performs history cleanup (see 6.3), writes a `Done` chunk with message "Cancelled by user.", sets status to `Cancelled`, and returns.
6. Frontend's next poll receives the `done` chunk and/or `cancelled` status, stops polling, transitions the technical container to cancelled visual state (see 6.4), and resets the UI.
4. Frontend performs an **immediate local hard stop**:
- sets `isCancellationRequested = true`
- aborts any in-flight run request (`AbortController`)
- aborts any in-flight poll request and clears scheduled polling timeout
- renders the cancelled state in the current response container
- resets submit/cancel controls back to idle
5. Frontend sends the cancel request as best effort. If Stop is clicked before `run()` returns a `sessionId`, the frontend still marks the turn cancelled locally and, if a late `run()` response does arrive with a session ID, it sends cancel for that session before returning.
6. Controller also triggers a best-effort runtime interruption via `WorkspaceToolingServiceInterface::stopAgentContainersForConversation(...)` so long-running tool/runtime containers are stopped quickly.
7. The handler detects `Cancelling` at the next DBAL status check (see 6.2), performs history cleanup (see 6.3), writes a `Done` chunk with message "Cancelled by user.", sets status to `Cancelled`, and returns.
8. Any late poll payload arriving after Stop is ignored by the frontend (`isCancellationRequested` guard), ensuring no further streamed tokens or tool thoughts are rendered after user cancellation.

### 6.2 Why Cooperative Cancellation? (And Why Not `refresh()`?)

Expand Down Expand Up @@ -319,10 +326,19 @@ Cancelled turns must be visually distinguishable from completed or failed turns.
The technical container styling uses `getCancelledContainerStyle()` (amber/orange palette) instead of `getCompletedContainerStyle()` (green palette). This applies in two code paths:

1. **Page reload** — `renderCompletedTurnsTechnicalContainers()` checks `turn.status === "cancelled"` and uses the amber style.
2. **Live cancellation** — `handleChunk()` detects the cancellation done-chunk and passes `cancelled: true` to `markTechnicalContainerComplete()`.
2. **Live cancellation** — `handleCancel()` immediately calls `renderCancelledState()`; any late chunk payload is ignored because cancellation has already been acknowledged locally.

**CSS pitfall:** The technical container header has a shimmer animation (`@keyframes shimmer`) that gives a "working" effect. This animation is explicitly stopped via CSS selectors for completed (`from-green-50/80`) and failed (`from-red-50/80`) states. The amber cancelled state (`from-amber-50/80`) must also be included in this CSS stop-list, otherwise a cancelled container shows an active shimmer over a static amber background — misleadingly suggesting work is still happening.

### 6.5 Faulty Prompt Inputs (e.g. Unresolved Placeholders)

Prompts that contain unresolved placeholders (for example `FACEBOOK-PIXEL-ID`) can cause the agent to spend extra cycles in tool/inference loops while trying to resolve invalid state. The cancellation design above is intentionally resilient to this:

1. **Immediate UI stop** prevents additional tokens/thoughts from being rendered after the user clicks Stop.
2. **Backend cancel flag (`Cancelling`)** ensures the worker exits cooperatively at the next loop check.
3. **Best-effort container interruption** reduces time spent in long-running runtime/tool execution after Stop.
4. **Synthetic assistant cancellation message** keeps history well-formed so follow-up prompts do not get derailed by the interrupted turn.

---

## 7. Stale Session Recovery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ public function __invoke(RunEditSessionMessage $message): void
$session->setStatus(EditSessionStatus::Running);
$this->entityManager->flush();

$conversation = $session->getConversation();

try {
// Load previous messages from conversation
$previousMessages = $this->loadPreviousMessages($session);
$conversation = $session->getConversation();

// Set execution context for agent container execution
$workspace = $this->workspaceMgmtFacade->getWorkspaceById($conversation->getWorkspaceId());
Expand Down Expand Up @@ -121,28 +122,8 @@ public function __invoke(RunEditSessionMessage $message): void
$streamEndedWithFailure = false;

foreach ($generator as $chunk) {
// Cooperative cancellation: check status directly via DBAL to avoid
// entityManager->refresh() which fails on readonly entity properties.
$currentStatus = $this->entityManager->getConnection()->fetchOne(
'SELECT status FROM edit_sessions WHERE id = ?',
[$session->getId()]
);

if ($currentStatus === EditSessionStatus::Cancelling->value) {
// Persist a synthetic assistant message so the LLM on the next turn
// understands this turn was interrupted and won't try to answer it.
new ConversationMessage(
$conversation,
ConversationMessageRole::Assistant,
json_encode(
['content' => '[Cancelled by the user — disregard this turn.]'],
JSON_THROW_ON_ERROR
)
);

EditSessionChunk::createDoneChunk($session, false, 'Cancelled by user.');
$session->setStatus(EditSessionStatus::Cancelled);
$this->entityManager->flush();
if ($this->isCancellationRequested($session)) {
$this->finalizeCancelledSession($session, $conversation);

return;
}
Expand All @@ -159,6 +140,12 @@ public function __invoke(RunEditSessionMessage $message): void
// Persist new conversation messages
$this->persistConversationMessage($conversation, $chunk->message);
} elseif ($chunk->chunkType === EditStreamChunkType::Done) {
if (($chunk->success ?? false) !== true && $this->isCancellationRequested($session)) {
$this->finalizeCancelledSession($session, $conversation);

return;
}

$streamEndedWithFailure = ($chunk->success ?? false) !== true;
EditSessionChunk::createDoneChunk(
$session,
Expand All @@ -171,6 +158,13 @@ public function __invoke(RunEditSessionMessage $message): void
}

if ($streamEndedWithFailure) {
if ($this->isCancellationRequested($session)) {
$session->setStatus(EditSessionStatus::Cancelled);
$this->entityManager->flush();

return;
}

$session->setStatus(EditSessionStatus::Failed);
$this->entityManager->flush();

Expand All @@ -183,6 +177,12 @@ public function __invoke(RunEditSessionMessage $message): void
// Commit and push changes after successful edit session
$this->commitChangesAfterEdit($conversation, $session);
} catch (Throwable $e) {
if ($this->isCancellationRequested($session)) {
$this->finalizeCancelledSession($session, $conversation);

return;
}

$this->logger->error('EditSession failed', [
'sessionId' => $message->sessionId,
'error' => $e->getMessage(),
Expand Down Expand Up @@ -309,4 +309,44 @@ private function truncateMessage(string $message, int $maxLength): string

return mb_substr($message, 0, $maxLength - 3) . '...';
}

/**
* @phpstan-impure
*/
private function isCancellationRequested(EditSession $session): bool
{
if ($session->getStatus() === EditSessionStatus::Cancelling) {
return true;
}

$sessionId = $session->getId();
if ($sessionId === null) {
return false;
}

// Query status directly to detect cancellation from parallel requests immediately.
$currentStatus = $this->entityManager->getConnection()->fetchOne(
'SELECT status FROM edit_sessions WHERE id = ?',
[$sessionId]
);

return $currentStatus === EditSessionStatus::Cancelling->value;
}

private function finalizeCancelledSession(EditSession $session, Conversation $conversation): void
{
// Keep conversation context consistent for future turns.
new ConversationMessage(
$conversation,
ConversationMessageRole::Assistant,
json_encode(
['content' => '[Cancelled by the user — disregard this turn.]'],
JSON_THROW_ON_ERROR
)
);

EditSessionChunk::createDoneChunk($session, false, 'Cancelled by user.');
$session->setStatus(EditSessionStatus::Cancelled);
$this->entityManager->flush();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use App\RemoteContentAssets\Facade\RemoteContentAssetsFacadeInterface;
use App\WorkspaceMgmt\Facade\Enum\WorkspaceStatus;
use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface;
use App\WorkspaceTooling\Facade\WorkspaceToolingServiceInterface;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
Expand All @@ -49,17 +50,18 @@
final class ChatBasedContentEditorController extends AbstractController
{
public function __construct(
private readonly ConversationService $conversationService,
private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade,
private readonly ProjectMgmtFacadeInterface $projectMgmtFacade,
private readonly AccountFacadeInterface $accountFacade,
private readonly EntityManagerInterface $entityManager,
private readonly MessageBusInterface $messageBus,
private readonly DistFileScannerInterface $distFileScanner,
private readonly ConversationContextUsageService $contextUsageService,
private readonly TranslatorInterface $translator,
private readonly PromptSuggestionsService $promptSuggestionsService,
private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade,
private readonly ConversationService $conversationService,
private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade,
private readonly ProjectMgmtFacadeInterface $projectMgmtFacade,
private readonly AccountFacadeInterface $accountFacade,
private readonly EntityManagerInterface $entityManager,
private readonly MessageBusInterface $messageBus,
private readonly DistFileScannerInterface $distFileScanner,
private readonly ConversationContextUsageService $contextUsageService,
private readonly TranslatorInterface $translator,
private readonly PromptSuggestionsService $promptSuggestionsService,
private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade,
private readonly WorkspaceToolingServiceInterface $workspaceToolingFacade,
) {
}

Expand Down Expand Up @@ -656,6 +658,19 @@ public function cancel(
$session->setStatus(EditSessionStatus::Cancelling);
$this->entityManager->flush();

$conversationId = $conversation->getId();
if ($conversationId !== null) {
try {
// Best-effort hard stop for long-running tool/runtime containers.
$this->workspaceToolingFacade->stopAgentContainersForConversation(
$conversation->getWorkspaceId(),
$conversationId
);
} catch (Throwable) {
// If runtime interruption fails, cooperative cancellation still applies.
}
}

return $this->json(['success' => true]);
}

Expand Down
Loading