* Execute a synthetic heartbeat turn for an idle workspace. * * This path is frontend-independent: heartbeats still run even if no UI is open. * Throws on failure so HeartbeatService can log and continue with the next workspace.
(workspaceId: string)
| 9202 | * Throws on failure so HeartbeatService can log and continue with the next workspace. |
| 9203 | */ |
| 9204 | async executeHeartbeat(workspaceId: string): Promise<void> { |
| 9205 | assert(workspaceId.trim().length > 0, "executeHeartbeat requires a non-empty workspaceId"); |
| 9206 | |
| 9207 | const heartbeatRequest = await this.buildHeartbeatRequest(workspaceId); |
| 9208 | const session = this.getOrCreateSession(workspaceId); |
| 9209 | if (session.isBusy()) { |
| 9210 | throw new Error( |
| 9211 | "Failed to execute heartbeat: Workspace is busy; idle-only send was skipped." |
| 9212 | ); |
| 9213 | } |
| 9214 | if (session.hasQueuedMessages()) { |
| 9215 | throw new Error( |
| 9216 | "Failed to execute heartbeat: Workspace has queued user input; idle-only send was skipped." |
| 9217 | ); |
| 9218 | } |
| 9219 | |
| 9220 | log.info("Executing heartbeat", { |
| 9221 | workspaceId, |
| 9222 | contextMode: heartbeatRequest.contextMode, |
| 9223 | model: heartbeatRequest.sendOptions.model, |
| 9224 | agentId: heartbeatRequest.sendOptions.agentId, |
| 9225 | }); |
| 9226 | |
| 9227 | switch (heartbeatRequest.contextMode) { |
| 9228 | case "normal": |
| 9229 | await this.dispatchHeartbeatMessage(workspaceId, heartbeatRequest); |
| 9230 | return; |
| 9231 | case "compact": |
| 9232 | await this.dispatchHeartbeatCompactionRequest(workspaceId, heartbeatRequest); |
| 9233 | return; |
| 9234 | case "reset": { |
| 9235 | const appendResult = await session.appendHeartbeatContextResetBoundary({ |
| 9236 | boundaryText: HEARTBEAT_RESET_BOUNDARY_MESSAGE, |
| 9237 | pendingFollowUp: heartbeatRequest.followUp, |
| 9238 | }); |
| 9239 | if (!appendResult.success) { |
| 9240 | throw new Error(`Failed to execute heartbeat: ${appendResult.error}`); |
| 9241 | } |
| 9242 | |
| 9243 | const dispatched = await session.dispatchPendingCompactionFollowUpIfNeeded( |
| 9244 | appendResult.data.summaryMessageId |
| 9245 | ); |
| 9246 | if (!dispatched) { |
| 9247 | log.info("Skipped heartbeat follow-up after reset boundary", { |
| 9248 | workspaceId, |
| 9249 | contextMode: heartbeatRequest.contextMode, |
| 9250 | }); |
| 9251 | } |
| 9252 | return; |
| 9253 | } |
| 9254 | default: { |
| 9255 | const exhaustiveContextMode: never = heartbeatRequest.contextMode; |
| 9256 | throw new Error(`Unhandled heartbeat context mode: ${String(exhaustiveContextMode)}`); |
| 9257 | } |
| 9258 | } |
| 9259 | } |
| 9260 | |
| 9261 | private async buildHeartbeatRequest(workspaceId: string): Promise<HeartbeatExecutionRequest> { |
no test coverage detected