Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ ROLLOUT_SIGNAL_SECRET=secret
LOCK_DSN="${DATABASE_URL}"
###< symfony/lock ###

CHAT_SESSION_TIMEOUT_MINUTES=5

LLM_CONTENT_EDITOR_OPENAI_API_KEY=your-key-here

###> sitebuilder/llm-wire-log ###
Expand Down
4 changes: 4 additions & 0 deletions config/packages/chat_based_content_editor.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
parameters:
chat_based_content_editor.bytes_per_token_estimate: 4
# Minutes of inactivity after which a conversation is auto-ended (only when conversation tab is not visible or no heartbeats).
# Set CHAT_SESSION_TIMEOUT_MINUTES in .env or .env.local to override.
chat_based_content_editor.session_timeout_minutes_default: 5
chat_based_content_editor.session_timeout_minutes: "%env(default:chat_based_content_editor.session_timeout_minutes_default:int:CHAT_SESSION_TIMEOUT_MINUTES)%"
# Estimated bytes for system prompt (included in "current context" for the budget bar)
chat_based_content_editor.system_prompt_bytes_estimate: 8000
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ services:
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
$sessionTimeoutMinutes: "%chat_based_content_editor.session_timeout_minutes%"

# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
Expand Down Expand Up @@ -132,6 +134,8 @@ services:

App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface:
class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade
arguments:
$sessionTimeoutMinutes: "%chat_based_content_editor.session_timeout_minutes%"

# Domain service bindings
App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface:
Expand Down
14 changes: 10 additions & 4 deletions src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class ChatBasedContentEditorFacade implements ChatBasedContentEditorFacade
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade,
private readonly int $sessionTimeoutMinutes,
) {
}

Expand Down Expand Up @@ -63,23 +64,28 @@ public function getOngoingConversationUserId(string $workspaceId): ?string
return $conversation?->getUserId();
}

public function releaseStaleConversations(int $timeoutMinutes = 5): array
public function releaseStaleConversations(?int $timeoutMinutes = null): array
{
$cutoffTime = DateAndTimeService::getDateTimeImmutable()->modify("-{$timeoutMinutes} minutes");
$minutes = $timeoutMinutes ?? $this->sessionTimeoutMinutes;
$cutoffTime = DateAndTimeService::getDateTimeImmutable()->modify("-{$minutes} minutes");

// Find ongoing conversations where:
// - lastActivityAt is set and is older than cutoff time, OR
// - lastActivityAt is null and createdAt is older than cutoff time (legacy/new conversations)
// - no edit session is currently running (agent not working),
// - and (lastActivityAt is set and older than cutoff, OR lastActivityAt is null and createdAt older than cutoff)
/** @var list<Conversation> $staleConversations */
$staleConversations = $this->entityManager->createQueryBuilder()
->select('c')
->from(Conversation::class, 'c')
->where('c.status = :status')
->andWhere(
'NOT EXISTS (SELECT 1 FROM ' . EditSession::class . ' e WHERE e.conversation = c AND e.status = :runningStatus)'
)
->andWhere(
'(c.lastActivityAt IS NOT NULL AND c.lastActivityAt < :cutoffTime) OR ' .
'(c.lastActivityAt IS NULL AND c.createdAt < :cutoffTime)'
)
->setParameter('status', ConversationStatus::ONGOING)
->setParameter('runningStatus', EditSessionStatus::Running)
->setParameter('cutoffTime', $cutoffTime)
->getQuery()
->getResult();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ public function getOngoingConversationUserId(string $workspaceId): ?string;
* A conversation is considered stale if the user hasn't sent a heartbeat
* (updated lastActivityAt) within the specified timeout.
*
* @param int $timeoutMinutes number of minutes after which a conversation is considered stale
* @param int|null $timeoutMinutes number of minutes after which a conversation is considered stale (default: configured session_timeout_minutes)
*
* @return list<string> list of workspace IDs that were released
*/
public function releaseStaleConversations(int $timeoutMinutes = 5): array;
public function releaseStaleConversations(?int $timeoutMinutes = null): array;

/**
* Get the ID of the latest conversation for a workspace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,13 @@ public function __invoke(RunEditSessionMessage $message): void
$session->setStatus(EditSessionStatus::Running);
$this->entityManager->flush();

$conversationId = null;

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

// Set execution context for agent container execution
$workspace = $this->workspaceMgmtFacade->getWorkspaceById($conversation->getWorkspaceId());
Expand Down Expand Up @@ -182,7 +185,14 @@ public function __invoke(RunEditSessionMessage $message): void
$session->setStatus(EditSessionStatus::Failed);
$this->entityManager->flush();
} finally {
// Always clear execution context
// Refresh last activity when agent finishes so 5-min inactivity countdown starts from now
if ($conversationId !== null) {
$conversationToUpdate = $this->entityManager->find(Conversation::class, $conversationId);
if ($conversationToUpdate !== null) {
$conversationToUpdate->updateLastActivity();
$this->entityManager->flush();
}
}
$this->executionContext->clearContext();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public function __construct(
private readonly TranslatorInterface $translator,
private readonly PromptSuggestionsService $promptSuggestionsService,
private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade,
private readonly int $sessionTimeoutMinutes,
) {
}

Expand Down Expand Up @@ -357,6 +358,7 @@ public function show(
] : null,
'remoteAssetBrowserWindowSize' => RemoteContentAssetsFacadeInterface::BROWSER_WINDOW_SIZE,
'promptSuggestions' => $promptSuggestions,
'sessionTimeoutMinutes' => $this->sessionTimeoutMinutes,
]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,23 @@ export default class extends Controller {
if (activeSession && activeSession.id) {
this.resumeActiveSession(activeSession);
}

// Reset session countdown when user types in the instruction field
if (this.hasInstructionTarget) {
this.instructionInputListener = () => {
document.dispatchEvent(new CustomEvent("chat-based-content-editor:user-typed"));
};
this.instructionTarget.addEventListener("input", this.instructionInputListener);
}
}

private instructionInputListener: (() => void) | null = null;

disconnect(): void {
if (this.instructionInputListener !== null && this.hasInstructionTarget) {
this.instructionTarget.removeEventListener("input", this.instructionInputListener);
this.instructionInputListener = null;
}
this.stopPolling();
this.stopContextUsagePolling();
}
Expand Down Expand Up @@ -350,6 +364,7 @@ export default class extends Controller {
pollUrl: this.pollUrlTemplateValue.replace("__SESSION_ID__", sessionId),
};
this.isPollingActive = true;
document.dispatchEvent(new CustomEvent("chat-based-content-editor:agent-work-started"));
this.pollSession();
}

Expand Down Expand Up @@ -421,6 +436,9 @@ export default class extends Controller {
}

private stopPolling(): void {
if (this.isPollingActive) {
document.dispatchEvent(new CustomEvent("chat-based-content-editor:agent-work-finished"));
}
this.isPollingActive = false;
if (this.pollingTimeoutId !== null) {
clearTimeout(this.pollingTimeoutId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,116 @@ import { Controller } from "@hotwired/stimulus";
* Stimulus controller for sending periodic heartbeats to track user presence.
* Prevents stale conversation locks by updating the lastActivityAt timestamp.
*
* - Heartbeats are sent only when the tab is visible, so switching to the project
* page (or another tab) stops updates and the session can auto-end after timeout.
* - Countdown runs to 0 and only resets when the user clicks "Continue" or returns to the tab.
* Automatic heartbeats do NOT reset the countdown, so the session ends when countdown hits 0.
* - While the agent is working, the countdown is reset to full and frozen; it resumes when the agent is done.
* - Typing in the instruction field resets the countdown.
*
* Uses non-overlapping polling: next heartbeat is scheduled only after the
* current one completes, preventing request pile-up on slow connections.
*/
export default class extends Controller {
static values = {
heartbeatUrl: String,
interval: { type: Number, default: 10000 }, // 10 seconds default
interval: { type: Number, default: 10000 }, // 10 seconds
sessionTimeoutMinutes: { type: Number, default: 5 },
translations: Object,
finishUrl: String,
finishCsrfToken: String,
projectListUrl: String,
};

static targets = ["countdown"];

declare readonly heartbeatUrlValue: string;
declare readonly intervalValue: number;
declare readonly sessionTimeoutMinutesValue: number;
declare readonly translationsValue: { endsIn: string; continue: string };
declare readonly finishUrlValue: string;
declare readonly finishCsrfTokenValue: string;
declare readonly projectListUrlValue: string;
declare readonly countdownTarget: HTMLElement | undefined;

private heartbeatTimeoutId: ReturnType<typeof setTimeout> | null = null;
private countdownTickId: ReturnType<typeof setInterval> | null = null;
private countdownSeconds: number = 0;
private isActive: boolean = false;
private sessionEnded: boolean = false;
private visibilityListener: (() => void) | null = null;
private agentWorkStartedListener: (() => void) | null = null;
private agentWorkFinishedListener: (() => void) | null = null;
private userTypedListener: (() => void) | null = null;

connect(): void {
this.isActive = true;
this.sendHeartbeat();
this.countdownSeconds = this.sessionTimeoutMinutesValue * 60;
this.updateCountdownDisplay();
this.startCountdownTick();
this.visibilityListener = () => this.handleVisibilityChange();
document.addEventListener("visibilitychange", this.visibilityListener);

this.agentWorkStartedListener = () => this.onAgentWorkStarted();
this.agentWorkFinishedListener = () => this.onAgentWorkFinished();
document.addEventListener("chat-based-content-editor:agent-work-started", this.agentWorkStartedListener);
document.addEventListener("chat-based-content-editor:agent-work-finished", this.agentWorkFinishedListener);

this.userTypedListener = () => this.resetCountdown();
document.addEventListener("chat-based-content-editor:user-typed", this.userTypedListener);

if (document.visibilityState === "visible") {
this.sendHeartbeat();
}
}

disconnect(): void {
this.isActive = false;
this.stopHeartbeat();
this.stopCountdownTick();
if (this.visibilityListener !== null) {
document.removeEventListener("visibilitychange", this.visibilityListener);
}
if (this.agentWorkStartedListener !== null) {
document.removeEventListener("chat-based-content-editor:agent-work-started", this.agentWorkStartedListener);
}
if (this.agentWorkFinishedListener !== null) {
document.removeEventListener(
"chat-based-content-editor:agent-work-finished",
this.agentWorkFinishedListener,
);
}
if (this.userTypedListener !== null) {
document.removeEventListener("chat-based-content-editor:user-typed", this.userTypedListener);
}
}

/**
* Called when the user clicks "Continue" to extend the session (sends heartbeat and resets countdown).
*/
continueClicked(): void {
if (document.visibilityState === "visible") {
this.resetCountdown();
this.sendHeartbeat();
}
}

private handleVisibilityChange(): void {
if (document.visibilityState === "visible") {
this.resetCountdown();
this.sendHeartbeat();
} else {
this.stopHeartbeat();
}
}

private onAgentWorkStarted(): void {
this.resetCountdown();
this.stopCountdownTick();
}

private onAgentWorkFinished(): void {
this.startCountdownTick();
}

private stopHeartbeat(): void {
Expand All @@ -37,12 +124,77 @@ export default class extends Controller {
}

private scheduleNextHeartbeat(): void {
if (this.isActive) {
if (this.isActive && !this.sessionEnded && document.visibilityState === "visible") {
this.heartbeatTimeoutId = setTimeout(() => this.sendHeartbeat(), this.intervalValue);
}
}

private resetCountdown(): void {
this.countdownSeconds = this.sessionTimeoutMinutesValue * 60;
this.updateCountdownDisplay();
}

private startCountdownTick(): void {
this.stopCountdownTick();
this.countdownTickId = setInterval(() => {
if (this.countdownSeconds > 0) {
this.countdownSeconds -= 1;
this.updateCountdownDisplay();
} else if (!this.sessionEnded) {
this.endSessionDueToTimeout();
}
}, 1000);
}

private stopCountdownTick(): void {
if (this.countdownTickId !== null) {
clearInterval(this.countdownTickId);
this.countdownTickId = null;
}
}

private updateCountdownDisplay(): void {
if (this.countdownTarget == null) {
return;
}
const m = Math.floor(this.countdownSeconds / 60);
const s = this.countdownSeconds % 60;
const text = `${this.translationsValue.endsIn} ${m}:${s.toString().padStart(2, "0")}`;
this.countdownTarget.textContent = text;
}

/**
* When countdown reaches 0: finish the session on the server and redirect to project list.
*/
private async endSessionDueToTimeout(): Promise<void> {
if (this.sessionEnded) {
return;
}
this.sessionEnded = true;
this.stopHeartbeat();
this.stopCountdownTick();
this.isActive = false;

try {
const formData = new FormData();
formData.append("_csrf_token", this.finishCsrfTokenValue);
await fetch(this.finishUrlValue, {
method: "POST",
body: formData,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
} catch {
// Continue to redirect even if request failed
}
window.location.href = this.projectListUrlValue;
}

private async sendHeartbeat(): Promise<void> {
if (this.sessionEnded) {
return;
}
try {
const response = await fetch(this.heartbeatUrlValue, {
method: "POST",
Expand All @@ -52,14 +204,12 @@ export default class extends Controller {
});

if (!response.ok) {
// If the conversation is no longer accessible, stop heartbeating
// This could happen if the conversation was finished or the user was logged out
if (response.status === 403 || response.status === 404 || response.status === 400) {
this.isActive = false;

return;
}
}
// Do NOT reset countdown on automatic heartbeat – only "Continue" and tab focus reset it
} catch {
// Network error - keep trying, might be temporary
}
Expand Down
Loading